Overview
I've always been the one who stops to read the plaque. Wandering around a new city or honestly just walking around my own suburb in the Shire, I'm forever wondering what actually happened on the bit of ground I'm standing on. Who was here first, what it all looked like before the buildings went up, why the street has the name it does. The trouble is the plaque is almost never there. When it is you get four sentences on a bit of brass and then you're back to guessing. So I'd end up standing on a footpath with five Wikipedia tabs open trying to stitch a story together myself. It sort of works. It's just a rubbish way to be curious about a place.
That's when it hit me. The models are genuinely good at this kind of writing now, my phone already knows exactly where I'm standing and it can talk. What if the whole thing was one button? Tap it once and something reads you the story of wherever you are while you keep walking. So that's what I built! Localore takes your GPS position, has Gemini write a proper narrative about that exact spot (the history, the people who lived there, the local plants and animals, the Indigenous story of the Country and the things worth walking over to see) and then reads the whole thing aloud in a natural voice. It's been live on the App Store since the 30th of July, where it's listed as Localore: Audio Tour Guide.
What I built
There are four tabs along the bottom (Discover, History, Premium and Settings) and just about everything hangs off the one button on Discover.
- Discover is the button and a search radius. You tap, it grabs your position, reverse geocodes it to a place name and starts writing. Everything else on that screen is deliberately out of the way.
- The story screen takes over the whole display the instant the request is authorised. You get a composing orb while the model warms up, then the words reveal themselves as they arrive and the narration starts on the first sentence rather than waiting for the last one. When it finishes there's a Tell me another button to go again.
- History keeps your last 50 stories, so the walk you did in a new city is still there a month later.
- Premium shows how many free stories you've got left and is where you subscribe.
- Settings is the dials. Search radius from 1 to 100 km, kilometres or miles, story length from a quick 2 minutes up to a detailed 5, the device voice and full dark mode.
It's free to start with no account and no sign up, which was a deliberate call. You get 10 stories and your first 5 come through in the natural Google voice so you can hear the app at its best before you decide anything. Premium is $4.99 USD a month and gets you the natural voice on every story, 50 stories a month, a narrator persona (warm and curious, witty and erudite or sardonic and sharp) and a custom topic that gets woven into every story, so if you're into birds or architecture or street art it'll go looking for that wherever you are.
How it works
The app is Expo and React Native with TypeScript, styled with NativeWind. Everything that costs money lives behind Supabase edge functions running on Deno.
generate-storytakes the place name, the radius and your settings, reserves a story against your quota with an atomic Postgres function, builds the prompt and streams Gemini 3.5 Flash back to the phone as NDJSON. Asnapshotline first so the quota counter on screen updates before any text lands, then achunkline per piece of story, then adoneor anerror.synthesize-speechchecks server side whether you're actually entitled to the natural voice, validates the requested voice against an allowlist, then hashes the text and the voice together into a cache key. If that MP3 already exists in storage it hands back a signed URL and Google never gets called. If not it synthesises it, uploads it and caches it for next time.- The narration pipeline on the phone feeds the incoming text through an incremental sentence segmenter and then a ramped chunker. The first sentence goes out on its own so audio starts as early as possible, then sentences group into paragraph sized chunks after that, because every chunk boundary is a potential seam and fewer of them is better. Each chunk gets synthesised in order and pushed into a native audio playlist that plays them back to back.
- RevenueCat handles the Apple subscription and a webhook syncs the entitlement back into Supabase, so the database is always the source of truth for what you're allowed to do. As of 1.0.2 it also collects the Apple Ads attribution token, so any Apple Ads I run can be traced back to the campaign and keyword that actually brought in the subscriber.
The landing page and support site is a small Astro build on Cloudflare, with the contact form running through a Cloudflare function with Turnstile on it and Resend doing the delivery.
How it was built
I worked through the build with Claude Code, spec first then plan then implementation, driving every bit of pure logic out under tests. That's the sentence segmenter, the NDJSON decoder, the narration queue, the story view state machine, the place name composition and the review prompt thresholds. It's 87 tests across 15 suites now and they run in about a second, because none of them touch a native module. All the native wiring sits in its own thin adapter files that get verified on a real phone instead.
The bit I'd recommend to anyone though was the pre launch sweep. Rather than one big "review this app" pass, I ran four separate read only sweeps over the same commit with four different briefs. One for QA and correctness, one for security, one for cost and one for abuse protection. Then I merged the findings and deduped them. Giving each pass a single lens is what made it work. The cost sweep went looking at dollars per story and found something the security sweep would never have thought to check. Two of them also landed on the same unbounded spend problem from completely different directions, which is a pretty good sign it was real. Most of the sticking points below came straight out of that morning.
Sticking points
The API keys were sitting in the app bundle
The first working version called Gemini and Google TTS straight from the phone with the keys in EXPO_PUBLIC_ environment variables. The thing about EXPO_PUBLIC_ is that it gets inlined into the JavaScript bundle at build time, so those keys ship inside the app and anyone with a copy can pull them out. Neither key can be locked to a bundle ID either, so an extracted one is just free billable traffic on my account. The quota was client side too. The app read your story count, added one and wrote it back. The row level security policy was happily letting the client write whatever number it liked.
The fix was moving both calls behind edge functions so the keys never leave the server and replacing the read then write quota with one Postgres function that locks the user row, applies the monthly reset if it's due, checks your tier cap and increments, all in a single atomic step. The client can't count any more, it can only ask. Caching the synthesised audio on the way through was a nice bonus, because the same text in the same voice is now free the second time.
A minute of nothing
The original flow was completely serial. Generate the entire 450 word story, then send that whole story off to be synthesised into one MP3, then upload it, then sign a URL, then play it. About a minute of a blank screen before anything at all happened, which for an app whose entire pitch is "tap one button" is fatal.
Fixing it meant streaming the whole way down. The edge function switched to Gemini's streaming call and emits NDJSON over a chunked response. The client reads it with expo/fetch, which on Expo SDK 55 is the only fetch in React Native that gives you an incremental response.body to read from. Words now appear a few seconds after the tap. The audio side was the trickier half. My plan had me installing react-native-track-player for its gapless queue, which meant a new native module and a prebuild that I wasn't thrilled about. Turns out expo-audio ships a native audio playlist that does exactly the job, so the sink is about forty lines of adapter and that dependency never got installed at all.
Two rules kept the streaming from breaking things that already worked. Quota and validation still fail as a plain JSON 4xx before the stream ever opens, so the upgrade prompt still fires the way it always did. And the streaming request and its buffered fallback share one request ID, so an idempotent reservation means a fallback can never quietly charge you twice.
The retry that told you two stories at once
The stream reader caught any failure and retried, which is completely sensible right up until you think about when the failure happens. If your connection drops halfway through, that throw arrives after a few paragraphs are already on the screen and already being read out. The retry then generated a fresh and completely different story and appended it under the half finished one while pushing both of them into the same still playing narration. Flaky reception on a walk was all it took.
So the reader now tracks whether anything has actually been delivered. Retry and the buffered fallback only run while nothing has arrived. Once text is on the screen a failure is an error and that's it. And once a terminal event has come through, a later transport failure gets ignored completely, because an untidy connection teardown right after the final line isn't a failure at all, it's just the end.
The other half of that problem was quota. The reservation deliberately happens before Gemini is called so you can't spam retries for free, which means a model outage burns a story and gives you nothing back. Tapping the button again minted a brand new request ID and burnt another one. A free user could lose all 10 lifetime stories to a bad afternoon at Google and never see a word. Now a user retry reuses the same request ID, the reservation recognises it and Gemini gets another go at no cost.
The captcha that had to go
Free with no account means anyone can mint a fresh anonymous user and get 10 free Gemini stories, then throw it away and do it again. My first answer to that was a captcha on anonymous sign in. I put hCaptcha in, then migrated the whole lot to Cloudflare Turnstile. At the end of all that I still had a webview challenge sitting between a brand new user and the one button the app exists for. So I ripped the whole thing out along with two dependencies and put the money guards where the money actually is. There's now an aggregate monthly ceiling on free tier voice synthesis and a global monthly breaker on story generation, both of them server side. Someone can still churn anonymous users all day. The worst they can do is hit a capped bill instead of an open one.
The cost sweep turned up the other half of that in the same pass. The premium cap was 75 stories a month. A detailed story with the natural voice runs about eight cents all up. Five dollars a month minus Apple's 30% leaves $3.50, so a subscriber who actually used all 75 was costing me money. Realistic usage is about 15 stories and a healthy margin, but the cap is the only thing standing between you and the tail. It's 50 now.
Two rejections from App Review in one build
Build 40 came back knocked twice and both were fair.
The first was guideline 5.1.1(v). The subscribe button made you create an account before it would let you pay. The frustrating part is the app didn't need it. Everyone gets signed in anonymously on launch and that anonymous ID is already identified with RevenueCat, so purchases worked fine without an account. It was purely a UI gate I'd put there out of habit. Now you can buy straight away and account creation is an optional card afterwards. You can also do it from Settings whenever you feel like it.
The second was guideline 2.1(a), reported as "app loaded indefinitely" when creating an account. That one turned out to be genuinely nasty. In the version of Supabase's auth library I'm on, updateUser holds an internal lock while it awaits every one of your onAuthStateChange subscribers. My subscriber was async and called getSession(), which re-enters that same lock and then waits on the outer call that's still holding it. Circular wait, no timeout, so the promise never settles, the finally block never runs and the signup spinner spins forever. Deferring the whole callback body by one tick releases the lock first and breaks it. The sting is that one branch of that same callback was already doing exactly that, with a comment above it explaining why.
A radius that didn't actually scope anything
The radius dial went from 1 to 100 km and looked great, but the only thing the prompt ever received was a city name off the reverse geocode. So a 1 km story about a specific suburb and a 50 km story about the whole region were built from the same input and read almost identically. The dial was decoration.
Two things fixed it. The place name composition came out of the screen and into a pure function that folds in the district and street fields when the radius is 5 km or under, so a tight radius anchors on the actual neighbourhood instead of the city. Then I built a small eval harness that calls the deployed function with fixed location fixtures at 1 km, 10 km and 50 km and grades the stories against keyword allow and deny lists per radius. "Does the radius change the scope" went from something I squinted at to something I can run. A test pins the harness fixtures to the output of that same pure function so the two can never drift apart.
The story that never finished
In September I ran a full feature audit over the app in the simulator and it found one that had slipped right past me. Once the narration finished, the story screen just sat in its reading state forever. The Tell me another button never showed up. The App Store review prompt hangs off the narration ending too, so that never fired either. It happened on both stories I tried.
The culprit was the same expo-audio playlist that saved me from installing a new native module. On iOS the end of track notification can arrive before the queue player has actually moved on, so the playlist grabbed the track that had just finished and kept treating it as the current one. Every end notification after that got checked against the wrong track and thrown away. The index got stuck on the second of five tracks while the audio happily played out to the end and the final finish event never made it across to the JavaScript side. Expo had already fixed it upstream in SDK 57, but the latest SDK 55 release still had the same code. So rather than a big SDK bump I wrote a small pnpm patch that takes the next track from the queue's own list instead of trusting whatever the player says is current.
Because the fix lives in native Swift there's no Jest test that can reach it. The proof was a patched build where I could watch the index tick through every track and the button finally appear at the end. The JavaScript side was already right and already under test. It just never got told the story had ended.
The result
Localore went live at the end of July and it's genuinely become the first thing I open when I'm somewhere I don't know. Three months of nights and weekends turned into a small app that does one thing properly. You stand somewhere, you tap once and a few seconds later a voice is telling you what happened on that exact patch of ground while you keep walking. That's the whole product and I wouldn't want to add much more to it. It's had a couple of updates since launch too. 1.0.1 carried the narration fix along with a round of polish from that same audit, including the counter on Discover now saying Free Stories instead of Free Premium Stories, because only the first 5 of your 10 free stories actually get the premium voice. 1.0.2 is what's on the App Store now.
It's free to start and there's no sign up, so if you want a go it's on the App Store. You can also just point your phone camera at this.
There's a bit more about it over at localoreapp.com. If you're building anything that puts a paid model behind a free tier, do yourself a favour and put the aggregate monthly ceiling in on day one, before you write a single per user quota. Per user caps feel like protection and they're not, because the moment anyone can mint a fresh user for free the cap resets with them. The only number that actually protects you is the one counting the whole month across everybody. Happy coding!