The moment we knew something had to change
Last December we shipped a typo fix to iOS.
The label on the cancellation deadline ring read "Free cancelation" — one l short. A four-character diff in a Swift file. We pushed the patch, opened a build, submitted to App Store Connect, and waited 28 hours for review. Twenty-eight hours, for one missing letter that any user who'd seen the screen had already noticed.
Around the same time we were prototyping French support. The dashboard's "in 3 hours" label was being rendered by a copy of Intl.RelativeTimeFormat we'd reimplemented in Swift, and it kept disagreeing with the version on the web. English worked. French rounded differently. A pluralization bug only showed up at exactly 1 hour. We had two implementations of the same logic, drifting from each other in subtle ways.
This is the duplication tax. Every screen in a multi-platform product has a small army of formatting decisions hiding inside it — what icon to show, what shade of red the badge should be, whether "1 day" should be "tomorrow", how to render €146 in fr-FR. Ship the app on three clients (iOS, web, soon Android) and you pay the tax three times. Ship a fix and you wait for the slowest client's release cycle.
The fix we landed on — and the subject of this post — is to push every one of those decisions onto the server, and to expose one HTTP endpoint per screen that returns the entire view-model. Our iOS app doesn't know how to format a date. The server tells it.
Backend for Frontend, in one paragraph
The pattern has a name. Sam Newman wrote about it in 2015: Backend for Frontend (BFF). The original framing was an aggregation layer between many small microservices and one client, so the client wouldn't have to stitch together five HTTP calls just to render a screen. We use it differently. Our backend isn't a microservice mesh — it's a single NestJS API. (NestJS is a Node.js framework with TypeScript decorators and dependency injection. Think Spring or Rails for TypeScript.) The thing we're aggregating isn't network calls; it's decisions. The boundary that matters to us is the screen, not the service.
So we call our flavor an Experience API — one endpoint per screen, returning the screen's full view-model with every label, icon key, color, sort order, and pluralized string already resolved.
If those terms are new: a view-model is the data a UI screen actually renders, separated from the raw database rows behind it. A DTO (data transfer object) is the wire-level version of the same idea — a typed payload of what one HTTP request sends or receives. The Experience API returns one DTO per screen.
The shape
Five routes, all under /experience/v1/:
GET /experience/v1/dashboard
GET /experience/v1/bookings?status=active&limit=20
GET /experience/v1/bookings/:id
GET /experience/v1/alerts
GET /experience/v1/settingsEvery response uses the same envelope:
{
"data": { /* screen DTO */ },
"meta": {
"generatedAt": "2026-05-03T10:30:00.000Z",
"locale": "en",
"version": 1
}
}data is whatever the screen needs. meta.locale is the locale the server actually used, after parsing the client's Accept-Language header. version lets us cut a v2 of any single screen without disturbing the others.
A real fragment, from the booking-detail screen we serve to iOS:
{
"header": {
"iconKey": "bed.double.fill",
"title": "Hilton Paris",
"subtitle": "Paris, France · booking.com",
"primaryDateRangeLabel": "Jun 1 – 4",
"status": { "kind": "active", "label": "ACTIVE" },
"urgency": {
"level": "critical",
"label": "in 2 days",
"hoursLeft": 47.99
}
},
"deadlineHero": {
"kind": "ring",
"overlineLabel": "FREE CANCELLATION",
"ringProgress": 0.999,
"ringLevel": "warning",
"centerLabel": "1",
"centerSublabel": "days",
"expiryLabel": "Expires May 1 at 06:08 PM"
},
"paidCard": {
"primary": { "amount": 420, "currency": "EUR", "label": "€420" },
"perNightLabel": "€140 /night"
}
}Note what's already done by the time iOS receives this. iconKey is an SF Symbol name the server picked from the booking type. urgency.label is the localized "in 2 days" string. paidCard.primary.label is €420 — currency-formatted in fr-FR style if the user's Accept-Language was French. The Money object even ships the raw amount and currency code alongside the label, in case a future screen needs to re-format. But the default is: render the label.
The iOS view that consumes this is short. A SwiftUI Text(header.urgency.label) and we're done. There is no DateFormatter in our experience views. There is no NumberFormatter. There is no pluralRule.
The architecture
The five routes land in one NestJS controller, which delegates to one assembler service per screen. Assemblers don't talk to DynamoDB directly — they compose the existing CRUD services (BookingsService, AlertsService, WorkspacesService) in parallel.
The booking-detail assembler is fifteen lines. A quick orientation if you haven't used Nest: @Injectable() is what lets the framework hand BookingsService to the constructor for us, and WorkspaceCtx is a per-request bundle our middleware attaches — auth user, workspace id, and an i18n object holding the locale and a translation function t().
@Injectable()
export class BookingDetailExperienceService {
constructor(private readonly bookings: BookingsService) {}
async getDetail(ctx: WorkspaceCtx, bookingId: string): Promise<BookingDetailScreen> {
const { t, locale } = ctx.i18n;
const [detail, history, alertsRes, activitiesRes] = await Promise.all([
this.bookings.getBookingDetail(bookingId, ctx),
this.bookings.getPriceCheckHistory(bookingId, ctx),
this.bookings.getBookingAlerts(bookingId, ctx),
this.bookings.getBookingActivities(bookingId, ctx),
]).catch((err) => {
if (err instanceof NotFoundException) throw bookingNotFound(t);
throw err;
});
return this.assemble(detail, history, alertsRes, activitiesRes, locale, t);
}
// assemble() builds header, deadlineHero, paidCard, priceComparison, ...
}Three things to flag.
First, the four BookingsService calls happen in parallel. The CRUD services already exist for the web frontend — we're not building a duplicate data layer for mobile. We're composing.
Second, ctx.i18n carries both the resolved locale and a bound translation function t(key, params) that reads from api/messages/{en,fr}.json. Every label in the response goes through t() or one of our locale-aware formatters (formatMoney(amount, currency, locale), formatRelativeHours(hours, locale), formatDateRange(start, end, locale)).
Third, the only thing this method returns is a typed DTO. There's a Zod schema for BookingDetailScreen that enforces the shape — Zod is a TypeScript-first schema library that validates data at runtime and infers the static type from the same definition, so the schema and the type can never disagree. The assembler can't compile without satisfying it. Drift between the schema and the response is impossible — TypeScript catches it at build time, and an integration test catches the runtime version.
What lives on the server now
We started this with a narrow view. The first migration plan said "move icon mapping and date formatting." But once you start, you keep finding things. By the time the first three Experience screens shipped, five whole categories of work had moved off the client.
Formatting. Currency goes out as { amount: 420, currency: "EUR", label: "€420" } — the label is fr-FR for French users, en-US for English, with the right symbol position and digit grouping. Date ranges arrive pre-rendered as "Jun 1 – 4". Relative times come back as the labels you'd actually show — "in 2 days", "tomorrow 3:00 PM", "now". Icon keys are picked per booking type (bed.double.fill for hotels, airplane for flights, car.fill for car rentals); each client maps the string to its own symbol library — SF Symbols on iOS, Lucide on the web.
i18n. Every label in every response runs through t(key, params), reading api/messages/{en,fr}.json. The locale itself is resolved server-side from the client's Accept-Language header. ICU MessageFormat handles plurals ({count, plural, one {1 booking} other {# bookings}}) so neither client has to branch on count === 1.
Calculations. urgency.level (critical, warning, healthy) is derived from the cancellation deadline and the server's clock, never the device's. ringProgress — the 0–1 value driving the deadline ring's fill — is computed alongside it. Price comparison decides whether the latest check beats what the user paid, and ships { isCheaper, savings: { amount, label } }. The "typical range" badge — was this booking cheap, mid-range, or expensive against the last 60 days? — comes back as a precomputed bucket.
Business logic. Whether the celebration card appears (a cheaper rate has actually been found), whether the warning card appears (booking is missing info, deadline has passed), whether the price-comparison card appears at all — every gate is an assembler decision. The empty-state copy on the alerts screen ships only when both the active and cleared sections are genuinely empty; that condition lives in the assembler, not in three SwiftUI views racing to detect the same thing. Errors come pre-shaped too: bookingNotFound(t) turns into { code: "BOOKING_NOT_FOUND", title, message } with the title and message localized, ready to drop into a sheet.
Sorting and filtering. The dashboard returns bookings already sorted by urgency — clients don't decide what comes first. The alerts feed comes pre-bucketed into act_now, this_week, and earlier, with empty bands stripped. The cleared bucket is sorted newest-first and capped at 30, with the cap living in alert-cleared.ts rather than three different views racing to truncate the same array.
The rule we tell ourselves: if it's not a pixel, the server decided it.
The seven reasons it's been worth it
Pushing this much logic to the server has a real cost. Assemblers are non-trivial code. The payload is chunky. There's no per-entity HTTP cache because the unit of caching is the screen. Here's why we still think it's the right call.
1. Ship fixes without an App Store review. The headliner. A typo fix, a wrong icon, a subtly broken urgency threshold — all are an ecs deploy away, not an App Store review away. We've shipped half a dozen copy fixes since the migration that would previously have meant a 24–48 hour wait.
2. One source of truth for an upcoming Android app. When we start on Android, the work isn't "reimplement the formatting helpers in Kotlin." It's "decode JSON into Kotlin types and render the labels." The screen DTOs are the contract; the second mobile platform inherits everything we've already done.
3. Server-side logs catch screen bugs. When iOS misrenders a screen, the bug is in our server response — not in some user's phone we can't access. Every assembler call shows up in CloudWatch with the workspace, locale, and outgoing payload. We've debugged three production rendering bugs by reading logs, without ever touching the device.
4. i18n lives where the data lives. We added French in two days, and the iOS app was changed in zero places. Every label, every plural rule, every Intl.NumberFormat decision happens once, in api/src/i18n/format.ts, with memoized formatters that handle ~15 items per dashboard request without churning the garbage collector. Web and iOS render whatever string the server hands them.
5. Type safety on both clients, anchored to the same Zod schemas. Each screen's Zod schema is the single source of truth. The web frontend gets fully generated TypeScript types from the OpenAPI spec via openapi-typescript. The iOS app uses hand-written Codable structs (Swift's built-in JSON serialization protocol — any struct that adopts Codable decodes straight from a JSON payload) in a namespaced enum Experience { ... }. Hand-written sounds risky on its own — both clients are kept honest by the two enforcement loops described below.
6. Composition over duplication. The assembler's Promise.all over four existing service methods replaces what would otherwise be four separate REST calls stitched together by the client — with all the orchestration, error handling, and partial-failure logic that implies. The CRUD services are the same ones the web frontend uses. We're not building a parallel API for mobile.
7. Fixture-driven contract tests across stacks. This is the one that makes the whole thing work, and it deserves its own section.
The fixture loop
Here's the trick that keeps a hand-written Swift Codable from drifting silently away from a TypeScript Zod schema.
Every screen has an integration test under api/test/integration/experience/. The test seeds a workspace, seeds a booking, calls the assembler, and asserts the result parses against the Zod schema. Standard stuff. But there's a flag:
WRITE_EXPERIENCE_FIXTURES=1 (cd api && npm run test:integration)When that flag is set, the integration tests also dump their results to disk as JSON. A second script copies them into the iOS test bundle:
bash scripts/sync-experience-fixtures.sh
# → ios/StayHawkTests/Fixtures/sample-{en,fr}/{dashboard,bookings,booking-detail,alerts,settings}.jsonThe iOS test target then has one decode test per screen, per locale:
func testDecodeBookingDetail_en() throws {
let env = try decodeEnvelope(
locale: "en",
screen: "booking-detail",
as: Experience.BookingDetailScreen.self
)
XCTAssertEqual(env.meta.locale, "en")
}If a Swift field name disagrees with a TypeScript field name, this test fails. If the API adds a required field and iOS's struct doesn't have it, this test fails. If we change a casing convention, this test fails. The same JSON file is the API's wire-contract assertion and the iOS app's decode-test fixture. They cannot disagree.
The committed source of truth is ios/StayHawkTests/Fixtures/sample-{en,fr}/*.json. The output of WRITE_EXPERIENCE_FIXTURES=1 is gitignored — it gets regenerated on demand and synced. Three commands and the contract is locked back in sync.
And on the web side: codegen plus a drift check
The web frontend gets a different deal. It doesn't ship Codable structs — it gets fully generated TypeScript types from the OpenAPI spec (the standard JSON format that describes a REST API's routes and response shapes), plus a CI workflow whose only job is to fail the build if anyone forgets to regenerate them.
The chain has five links:
- The Zod schema in
api/src/experience/schemas/booking-detail.schema.ts— the source of truth for every field, type, and constraint. nestjs-zodwraps the schema as a DTO viacreateZodDto(), and@ApiOkResponse({ type, example })attaches the OpenAPI metadata Swagger needs.api/scripts/dump-openapi.tsboots the Nest app once, asks Swagger for the full spec, and writesapi/openapi.json. One file, the entire surface area of the API.scripts/generate-types.shrunsopenapi-typescriptagainst that JSON and writesfrontend/src/types/api.ts— currently 5,184 lines of paths, schemas, and operation types.- The Next.js code consumes them via
components["schemas"]["BookingDetailEnvelopeDto"]andpaths["/experience/v1/bookings/{id}"]["get"]. Afetch()against the wrong endpoint, or a misspelled field on the response, fails to compile.
Step 5 is where most teams stop, and where contracts go to die. Generated types only matter if everyone remembers to regenerate them — and people forget. So we wrote a 30-line GitHub Action whose entire job is to enforce the rule for us. .github/workflows/check-types.yml runs on every push and PR that touches api/src/**, frontend/src/types/api.ts, or the codegen script. It reinstalls dependencies, runs ./scripts/generate-types.sh, and then runs:
git diff --exit-code frontend/src/types/api.tsIf anything in api/src/** changed without a matching regeneration, the diff is non-empty, the action exits 1, and the PR is red. Reviewers see "Generated OpenAPI types are stale. Run ./scripts/generate-types.sh and commit the result." No one has to remember to run the script — CI runs it, and won't let the PR merge until the types are back in sync.
The result: same Zod schemas, two enforcement loops. iOS decodes a real JSON fixture and trusts the Codable struct will fail XCTest if the shape drifts. The web frontend trusts CI to fail the PR if the types drift. Different mechanisms, same goal — neither client can disagree with the API on the shape of a screen response without immediately hearing about it.
What we gave up
This pattern isn't free. Three things to know before adopting it.
One chunky payload, no per-entity HTTP cache. Booking-detail returns ~25 KB on a busy booking. We can't Cache-Control individual entities the way a REST API can. The mitigation is that the screen has natural cache boundaries (price-check history changes once a day; activity rows trickle in) and we lean on ETag (the HTTP "I already have version X, only respond if it changed" header) for the whole envelope where it matters.
Adding a screen variant means a backend deploy. We can't A/B test new sort orders or label copy without shipping API code. For a small team this is a feature — there's exactly one place to look. For a larger team it pushes some experimentation friction onto the API team that a smarter client would absorb.
Server work scales with screen complexity. A screen that composes ten data sources costs more CPU per request than a screen that fetches one entity. Our most expensive endpoint, the dashboard, makes four parallel DynamoDB queries plus locale-aware formatting on ~15 items. It's still well under our 200ms p95 latency budget (the threshold the slowest 5% of requests must beat), but the budget is real.
If your product is plain CRUD on a single entity, or a third-party-facing API where you don't control the clients, this isn't the right pattern. Stay with REST.
When the pattern fits
Same data shown on multiple clients. Locale-aware formatting that's expensive to keep in sync. A roadmap that includes a second or third mobile platform. An iOS app where you want the option to fix a typo without waiting on Apple.
The principle, as plainly as we can state it: the client renders, the server decides what to render. When that line is clean, every screen is a JSON file and every fix is a backend deploy. Whatever client we ship next inherits the work the previous ones already did.
