Paglipat used to be Next.js, Ktor, and Kotlin Multiplatform. The product that is live at paglipat.com is a static Astro site, two Go backends, and a Flutter app on Android and iOS.
This is not a ranking of frameworks. Kotlin Multiplatform, Compose Multiplatform, and Ktor are tools I still use. For this product, and for the way I want to operate it as one engineer, I prefer Astro on the public web, Go for the live APIs, and Flutter on the phone. I already wrote the first-stack lessons. This post is the successor: the runtime as it stands, the mobile Compose and Ktor issues that shaped that preference, and the end-to-end work the live stack shows.
Key Takeaways
- For Paglipat I prefer Astro, Go, and Flutter. That is a product choice, not a claim that they replace Kotlin Multiplatform, Compose Multiplatform, or Ktor in general.
- Compose Multiplatform 1.12.0 is the current mobile UI line. iOS accessibility and native text input have come a long way. For this search app I still wanted one Flutter UI I could ship to Play and the App Store.
- Ktor 3.5.2 is a strong Kotlin server. My Paglipat BFF had grown too many jobs for how I wanted to run flights and hotels, so those services are Go.
- The public web is HTML built at compile time. Live fares never wait on a Node or JVM process.
- Paglipat is also the end-to-end sample of those skills: Astro, Go, Flutter, plus a production Worker concierge.
The stack that is live today
Three clients, one edge, two Go services.

- Astro 7 static site — marketing pages, 15 locales, blog, legal, search chrome. Not live fares, hotel rates, or chat.
- Go flights BFF (
flights.paglipat.com) —/v1search, poll, prices, signed redirects. No database, no hotels. - Go hotels BFF (
api.paglipat.com) — catalog, autocomplete, live Agoda rates. Not flight search. - Flutter app — search, saved deals, hotels, tools, onboarding. Not settlement, tickets, or refunds.
- Cloudflare Worker — apex redirect, geo locale, cache, Turnstile, concierge. Not page HTML.
The Paglipat case study is the product story, including the concierge. This post is the runtime.
The name still means what it meant in Tagalog: to transfer, to move. The architecture now matches that. HTML is precomputed and cached close to the traveler. Anything that has to be live (a fare, a hotel rate, a visa lookup) is a small service with a hard contract.
Why this mix, for this product
The previous stack was honest for an Android engineer building a product alone: Kotlin everywhere it would fit, Next.js where the public site needed a real DOM, Supabase for auth and data. I still work that way on other products.
For Paglipat I wanted something simpler to operate: HTML for pages, a small Go binary for live fares, one Flutter UI for the phone.
Compose Multiplatform on mobile
The mobile question was Compose Multiplatform, not the web canvas. Share the UI in Kotlin on Android and iOS.
Compose Multiplatform 1.12.0, released 25 August 2026, is the current line (JetBrains, GitHub 1.12.0). Compose for iOS has been stable since 1.8. Accessibility on iOS maps Compose semantics to VoiceOver and the same navigation gestures as native UI (iOS accessibility docs). Later releases added RTL accessibility, scrollable-list accuracy, and text-field traits (What’s new in 1.8.2). 1.11.0 added experimental native text input on a UIView, so caret, selection, and the system context menu can match iOS when you opt in (Compose Multiplatform 1.11.0). 1.12.0 added LiveRegion semantics on iOS.
There are still gaps that mattered for how I wanted to ship a consumer search app:
- Material3’s
ColorSchemestill has no out-of-the-box high-contrast palette. You build that yourself and watchUIAccessibilityDarkerSystemColorsStatusDidChangeNotification(same iOS accessibility guide). - Native iOS text input is still the experimental path. The cross-platform field is the stable default.
- Compose on iOS is a semantics bridge onto UIKit, not UIKit widgets. Interop views do not get native accessibility for free unless you turn it on.
- Default back-gesture behaviour on iOS follows the Android pattern unless you change it (platform-specifics).
None of that makes CMP a poor framework. It is a strong choice for a Kotlin-first team that already lives in Compose. For Paglipat I am also the iOS engineer and the designer. I already teach Flutter. I wanted one UI codebase that ships to Play and the App Store from the same app. That is a preference for this product, not a verdict on CMP.
Kotlin 2.4.10 (14 July 2026) is the current KMP toolchain (Kotlin releases). Shared logic plus native UI is still how I work on client Android and iOS, and it is the pattern in the KMP clean architecture blueprint. Paglipat’s screens are the work, so I did not split a domain module from two presentation stacks before the first Play listing.
Ktor, and why these APIs are Go
The old backend was Ktor. The old Paglipat repo is still on Ktor 3.3.3. The current stable Ktor is 3.5.2, released 4 August 2026: rate limiting, I/O, OpenAPI, configuration, and a CIO request-handler leak on half-closed idle connections (Ktor releases, 3.5.2 changelog). 3.5.1 had already covered Kotlin 2.4 compiler compatibility, HTTP and cache behaviour, authentication, and engine bugs on JVM, Darwin, Android, and Windows.
I still like Ktor. I wrote Churust because I wanted that shape on Rust. I would reach for Ktor again on a Kotlin backend. For Paglipat the BFF had grown in a way I did not want to keep operating:
- One process owned flights, hotels, currency, admin, and auth-adjacent work.
- TravelPayouts JSON leaked into clients. Path aliases piled up (
/api/flights/searchversus/search/flights). - Proxying Supabase auth through Ktor ran into single-use refresh tokens. The client could hold a stale token while the server had already rotated it. I covered that in the first-stack post.
- Ktor Client still needs a different engine per platform in shared mobile code: OkHttp on Android, Darwin on iOS, CIO or Apache on JVM. Fine in a KMP module. Extra moving parts for a server whose job is “call TravelPayouts, filter, sign a redirect.”
- I wanted a flights proxy that boots in milliseconds and sits in a distroless image. A JVM heap floor is more process than I wanted for that hop.
Ktor 3.5.x is more stable than 3.3.3. Splitting flights and hotels into two Go binaries is what made the API surface smaller for me. That is the preference, not a claim that Go replaces Ktor.
Why Astro for the public web
Astro ships zero client JavaScript by default. Components render to HTML and CSS. You opt into a browser runtime with a client:* directive on the pieces that actually need it.
That is the islands architecture in the official docs, not a slogan I invented. Katie Sylor-Miller named component islands in 2019. Jason Miller wrote the pattern up in 2020. Astro made selective hydration the default instead of an advanced mode.
Paglipat is a content site with a search widget, not a single-page app that happens to have a homepage. The landing pages, the 15-locale routing (including RTL Arabic and Hebrew), the MDX blog, the legal and help corpus, the hreflang and per-locale sitemaps: all of that is HTML. The build emits on the order of 1,500 files. DigitalOcean App Platform serves the static output. Cloudflare sits in front.
The search box is an island. Live fares are a request to the Go BFF, or the TravelPayouts whitelabel where the page still uses it. They are not a reason to hydrate a React tree for a visa guide.
I rebuilt off Next.js for a boring reason. Most of the site is the same bytes until the next deploy. A static build plus an edge Worker for locale and redirects is cheaper, faster to first byte, and easier to reason about when something 404s in Japanese.
Astro also keeps writing close to markdown. The multilingual blog and the help pages are MDX. TypeScript stays in strict mode. Tailwind v4 is the styling layer. None of that required a custom server.
What Astro is not: the API. If a request needs a TravelPayouts search id, an Agoda rate, or a model with tools, it does not belong in output: 'static'.
Why Go for the backends
There are two Go services, not one “the backend.”
The flights BFF is a hexagon around net/http. Go 1.22 added method matching and wildcards to ServeMux so a production router can live in the standard library (Go blog, February 2024). Handlers talk to use cases. Only internal/travelpayouts knows upstream URLs, signatures, and the messy proposals / xterms JSON. Public JSON is camelCase under /v1. Errors are RFC 7807. Search is stateless: TravelPayouts owns search_id, this process holds TTL caches. Horizontal replicas are safe because there is no database.
The hotels BFF is a different shape on purpose. It loads an Agoda catalog into Postgres with PostGIS, then overlays live affiliate rates. Hotels need geography, slugs, and a catalog that is not a JSON file in git. Flights do not.
Both images are the same idea: CGO_ENABLED=0, copy the binary into gcr.io/distroless/static-debian12:nonroot. Distroless images contain the application and its runtime dependencies, not a shell or a package manager. Google’s distroless project describes these images as very small; the README puts static-debian13 around 2 MiB, about half of Alpine and a fraction of a full Debian image (Distroless README, retrieved 2026-09-03). Our flights image uses static-debian12. The healthcheck is the binary itself (/server -healthcheck). There is no curl in the image to abuse.
I chose Go for this job because I wanted a process that starts serving without a JVM warmup, a heap floor, or a framework to explain in the Dockerfile. Glasskube’s public write-up of a Java-to-Go move measured a light Spring Boot app at about eight seconds to start and a Go server ready in under 100 milliseconds (Glasskube, February 2025). That is one team’s app, not a universal law. It matches what I wanted here: a BFF that is cheap to replicate and honest about its dependencies.
Go is also a good language for this kind of I/O. The flights server spends its life waiting on TravelPayouts, filtering, enriching, converting currency, and signing a redirect. Goroutines are the default concurrency, not an extra library. A circuit breaker and retries sit in front of upstream. Tokens and markers never appear in logs. Missing required env fails at boot.
The booking path is the part people skip in architecture diagrams. POST /v1/searches/{id}/clicks returns a signed redirectUrl. GET /v1/redirect is an HMAC-verified hop, GET 302 or an HTML POST form, host-allowlisted. Affiliate identity stays host=www.paglipat.com. Paglipat never takes the card.
I still like Kotlin. I wrote Churust because I also like a Ktor-shaped API on Rust. For Paglipat’s flights and hotels services, Go’s standard library was enough.
Why Flutter for the mobile app
The Android app is on Google Play. iOS is on the App Store. The website remains the full product, including the concierge.
Flutter is the mobile choice I wanted here: one UI codebase that compiles to native ARM for Android and iOS. Flutter release builds AOT-compile Dart. An Android app bundle contains armeabi-v7a, arm64-v8a, and x86-64 by default (Flutter Android deployment docs). That is a real native binary, not a WebView of paglipat.com.
Impeller is the default renderer on Android API 29+ and the only renderer on iOS. Shaders are compiled at engine-build time so the first frames do not hitch on runtime shader compilation (Flutter Impeller docs, updated August 2026). The Paglipat app is Flutter 3.47, which is the line of releases where Impeller is on by default across the mobile and desktop targets I care about.
The app is feature-first: search, hotels, saved, history, eSIM, tools, owners, onboarding. State is Riverpod. Navigation is go_router. Networking is Dio, with a dedicated flights client on https://flights.paglipat.com and a hotels client on https://api.paglipat.com/api/v1. Freezed models and a GoFlightMapper sit on the /v1 contract. Saved deals and recent searches persist without an account.
I know Flutter well enough to teach it. The SOLID-in-Dart post is years old. That is not why Paglipat uses it. The reason is shipping speed for a UI-heavy consumer app when I am the Android engineer, the iOS engineer, and the designer.
Kotlin Multiplatform would have shared the HTTP and domain layer. I would still have written two UIs, or taken on Compose Multiplatform for the screens. For Sky-scale products, shared logic and native UI is often the split I want. For Paglipat I preferred Flutter so the Play listing and the App Store listing share the same presentation code.
The public site is still HTML. Flutter Web compiles to JavaScript and uses Skia in the browser (Flutter architectural overview). That is a different product from paglipat.com, which needs a DOM, crawlers, and 15 locales. Astro is the preference for that surface. The phone app is Flutter. They are not the same decision.
How the three talk
The web and the app do not share UI. They share contracts.
Flights go to /v1. JSON is camelCase. Dates are YYYY-MM-DD. IATA codes are uppercase. Cabin is economy | premium_economy | business | first. The Flutter client already speaks that language. The Astro site can call the same host. I am not maintaining a Kotlin JSON shape and a Dart JSON shape and a TypeScript JSON shape for the same poll.
Hotels stay on /api/v1 because that service has a catalog, not a search id. Mixing flights and hotels in one process was how the Ktor BFF got fat.
Static JSON is a third contract. The Worker and the site emit feeds the app can read without an account: blog indexes, DTV rules, relocation checklists, eSIM plans. The concierge reads the same catalog the website built. If a guide is retired, it disappears from the catalog instead of surviving as a hallucinated link.
Booking is a fourth contract, and it is deliberately narrow. Compare, click, signed redirect, partner. Ticket changes stay with the partner. That is a product rule with a cryptographic check behind it, not a footer disclaimer.
The Worker is not optional
The fourth piece is a Cloudflare Worker on paglipat.com and www.paglipat.com.
It 301s apex to www. It 410s ghost locale prefixes so Google can drop them. It picks a locale from a cookie, cf-ipcountry, and Accept-Language. It caches static HTML and assets with path-aware headers. It gates chat and subscribe with Turnstile. It runs a nightly retention cron because the privacy page, in every locale, promises transcripts are deleted after 90 days. A runbook is not a retention policy.
The concierge lives here, not in Astro. Models only choose tools. Rules come from verified data. The tools are check_visa, get_checklist, read_guide, search_hotels, and flight_prices. Tool descriptions say when to call them, because recent models reach for tools conservatively.
A router classifies the turn. It is allowed to decline a concierge call for greetings, thanks, off-topic, and injection. It is not allowed to answer a visa question. Ambiguity escalates. The failure mode is an extra model call, never a made-up day count. Volatile context (date, locale, page) sits in the last user message so the system prefix can stay cacheable. A test fails the build if that invariant breaks, because a volatile byte in the cached prefix does not throw. It silently bills full input price.
Daily spend is capped at five dollars. Twenty messages per IP per hour. Thirty turns or 60,000 output tokens per session. Guardrails run cheapest first: parse, budget, IP rate, then Turnstile. The cost ledger is exempt from the 90-day purge. It stores token counts and prices, not question text and not an IP hash.
That is the same production AI story as the case study, and it is why I keep Paglipat in the applied AI evidence base. The architecture point is simpler: the Worker is the live edge. Astro is the static corpus. Go is the travel data. Mixing those three in one Next.js server was how the old stack got unclear.
What this stack also proves
Paglipat is a product. It is also the public sample of work I hire for.
The website is Astro 7, TypeScript strict, MDX, 15 locales, hreflang, a static build, and an edge Worker. The flights and hotels APIs are Go 1.26 services with tests, OpenAPI, Docker, and production TLS. The mobile app is Flutter 3.47 on Google Play and the App Store, talking the same /v1 contract. The concierge is a tool-calling agent with budgets, Turnstile, and a retention job.
That is one product across web, backend, mobile, and applied AI. I still ship Kotlin Multiplatform, Compose, and Ktor on other work. This repo is the place you can see Astro, Go, and Flutter used together, in production, without a slide deck.
What this architecture is not
It is not “Go replaced Kotlin in my career.” I still write Kotlin for client work, KMP libraries, and Android. Paglipat’s flights and hotels services happened to want a single static binary.
It is not a claim that Flutter replaces native UI. For a regulated streaming or betting app with platform-specific media, I still want native. Paglipat is a search app.
It is not a hotel booking engine or an airline. Settlement is on the partner.
It is not the smallest possible stack. A Worker plus two Go services plus Astro plus Flutter is more moving parts than a Next.js monolith. Each part has a job I can explain without waving at “full stack.”
The constraint that shaped all of this is the same one that shapes the ML journal: I am one engineer. The architecture has to be operable at that staffing level. Static HTML, boring HTTP, and one mobile UI is how I keep it operable.
Frequently Asked Questions
Why not keep Next.js for paglipat.com?
Because most of the site is pages that only change when I deploy. Astro builds them to HTML, Cloudflare caches them, and the search widget is the island that talks to Go. Next.js can do static output. I was using it as a server for content that did not need one.
Why not Compose Multiplatform for the mobile app?
Compose Multiplatform 1.12.0 is a solid shared-UI stack on Android and iOS, with VoiceOver mapping, RTL accessibility, and an experimental native text-input path. I still use it in Kotlin-first work. For Paglipat I preferred Flutter: one UI I already teach, shipping to Play and the App Store from the same app. That is context for this product, not a ranking.
Why Go for the APIs instead of Ktor?
Ktor 3.5.2 is the current stable Kotlin server, and I would use it again on a Kotlin backend. My Paglipat BFF had become one process with leaked upstream JSON, path aliases, and auth-proxy edge cases. Go’s net/http, a distroless image, and a boot-time config check fit how I wanted to run flights. Hotels are a second Go service with Postgres because a catalog is not the same problem as a TravelPayouts search id.
Why Flutter instead of Kotlin Multiplatform with native UI?
KMP on Kotlin 2.4.10 is excellent at sharing domain and networking when each platform still gets a native UI. Paglipat’s app is the UI: search, calendar, hotels, saved deals. Flutter lets that UI ship to Android and iOS from one presentation codebase.
Is the iOS app live?
Yes. Android is on Google Play. iOS is on the App Store (Paglipat: Move to SE Asia). The website is the complete product, including the concierge.
Does Paglipat take payment?
No. It compares fares and hotel rates, then sends you to a booking partner. The redirect is signed. Ticket changes, refunds, and booking support stay with the partner.
Where does the AI concierge run?
On the Cloudflare Worker, with tools, a router that cannot invent visa rules, Turnstile, a daily budget, and a 90-day retention job. It is not an Astro page and it is not the Go BFF.
Happy coding!
David Cruz davthecoder.com paglipat.com

Loading comments…