Back to projects

Localore

An iOS app that turns wherever you are standing into a narrated story. Tap once and Gemini writes the history, nature and culture of that exact spot while a natural voice reads it aloud, streamed word by word so the narration starts on the first sentence instead of the last. Live on the App Store.

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.

The Localore Discover screen with the search radius and the Tell me a Localore story button

What I built

There are four tabs and just about everything hangs off that one button.

  • 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.
  • History keeps every story you've generated, so the walk you did in a new city is still there a month later.
  • 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.
The Skia composing orb while a story is being written A generated Localore story about Sydney being revealed word by word

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.

The Localore settings screen with search radius and story length controls The Localore subscription screen showing the free plan and premium upgrade

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-story takes 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. A snapshot line first so the quota counter on screen updates before any text lands, then a chunk line per piece of story, then a done or an error.
  • synthesize-speech checks 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.

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 79 tests across 14 suites and they run in under 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 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 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.

QR code linking to Localore on the App Store

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!

Stack

ExpoReact NativeTypeScriptSupabaseDeno Edge FunctionsPostgreSQLGoogle GeminiGoogle Cloud TTSRevenueCatReact Native SkiaNativeWindAstroCloudflareJestClaude Code

Timeline

May 2026 — August 2026