The offline first, multilingual audio tour app built with Expo
Users•React Native•Development••11 minutes read
Clinton Forster
Guest Author
Discover how Kuratour uses Expo and expo-audio with offline-first architecture to power GPS-triggered audio tours and white-label apps for tour operators.

This is a guest post from Clinton Forster - a passionate full stack developer from Down Under where his app (Kuratour) recently won the 2025 Innovation Award in the Adventure Tourism Awards ceremony. He first landed on our radar when he submitted Kuratour to the 2025 Expo App Awards.
...
Travel should feel like discovery, not logistics. But for many travelers, the best stories are hidden behind expensive roaming fees, spotty reception, or rigid tour group schedules. Kuratour was built to change that, turning your phone into a personal, location-aware tour guide that works everywhere - even at the edge of the world.
The vision for Kuratour
Kuratour is a multilingual, GPS-powered audio tour app built entirely with Expo. It delivers immersive walking and driving experiences through real-time location triggers, offline maps, and AI-generated routes. Today, Kuratour powers not just our own platform but a suite of white-labeled apps for tour operators across Australia and beyond.
In this post, we’ll dive into how we leveraged the Expo SDK to solve the "Offline-First" challenge and how we built a modern audio engine using the new expo-audio library.
The Expo foundation: modern React Native at scale
Kuratour is built entirely with the Expo SDK on the new architecture, using both first-party modules and community packages to deliver a native-quality experience.
Core modules:
expo-locationfor real-time GPS tracking and region-based triggersexpo-file-systemandexpo-sqlitefor offline data caching and media storageexpo-audiofor smooth multilingual playbackexpo-videofor a descriptive introductory multilingual tutorialexpo-splash-screenfor a polished startup experienceexpo-apple-authenticationfor seamless login on iOSexpo-applicationfor checking the app version to advise users of updates and making sure offline content is up to date@rnmapbox/mapsfor interactive, offline-enabled maps
Building an offline-first experience
For Kuratour, "Offline-First" isn't a feature - it's the core requirement. Travelers often lose reception in remote areas or turn off data to avoid roaming fees. To solve this:
- Map tiles, images, and audio are downloaded ahead of time using
expo-file-system. - Metadata and visited locations are stored locally with
expo-sqlite. - The app detects connectivity and switches automatically between online and offline modes.
- Real-time GPS triggers from
expo-locationensure narration starts at exactly the right moment.
Step 1: Managing structured data with SQLite
We use expo-sqlite to track downloaded tours, user progress, and localized settings. Below is a simplified version of our DatabaseService. We use a versioning system to handle schema migrations - crucial when you're adding features like "Observed Directions" to an existing user base.
import * as SQLite from "expo-sqlite";export class DatabaseService {private db: SQLite.SQLiteDatabase;private currentVersion = 3;constructor() {// Open (or create) the databasethis.db = SQLite.openDatabaseSync("db");this.initializeSchema();}private initializeSchema() {try {// 1. create schema table if it doesn't existthis.db.execSync(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY NOT NULL);`);// check db version and apply appropriate migrationsconst result = this.db.getFirstSync("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1") as DatabaseSchema;const dbVersion = result ? result.version : 0;if (dbVersion < this.currentVersion) {this.applyMigrations(dbVersion);}} catch (error) {console.error("Error initializing schema:", error);}}private applyMigrations(dbVersion: number) {try {if (dbVersion < 1) {this.db.execSync(`CREATE TABLE IF NOT EXISTS tours (id INTEGER PRIMARY KEY NOT NULL,offline_data TEXT,visited_locations TEXT);`);this.db.execSync("INSERT INTO schema_version (version) VALUES (1)");}if (dbVersion < 2) {// ... further migrations as necessary}} catch (error) {console.error("Error applying migrations:", error);}}// Save the entire tour object as a JSON string for offline retrievalasync setOfflineData(tourID: number, offlineData: any) {const tourAsString = JSON.stringify(offlineData);await this.db.runAsync("INSERT OR REPLACE INTO tours (id, offline_data) VALUES (?, ?)",[tourID, tourAsString]);}let databaseServiceInstance: DatabaseService | null = null;export function getDatabaseService(): DatabaseService {if (!databaseServiceInstance) {databaseServiceInstance = new DatabaseService();}return databaseServiceInstance;}}
Step 2: Orchestrating asset downloads
When a user taps "Download," we have to sync multiple resources: Mapbox tiles, MP3 audio files, and high-res images. We use expo-file-system to mirror our remote directory structure locally.
import { Directory, File, Paths } from "expo-file-system";const downloadFile = async (key: string) => {const CDNUrl = getUrl(key); // Your CDN/Storage URL to the fileconst dirName = key.substring(0, key.lastIndexOf("/"));const destinationDir = new Directory(Paths.document, dirName); // we are allowed to store data in the documents dir without prompting user// Ensure the directory existsif (!destinationDir.exists) {destinationDir.create({ intermediates: true });}const file = new File(destinationDir, key.split("/").pop() || "");if (!file.exists) {// Download the file from the remote URL to the local filesystemawait File.downloadFileAsync(CDNUrl, file);}return file.uri; // This local URI is what the <Audio> or <Image> components will use};
Step 3: Integrating with the UI
In the TourDownload component, we wrap these calls in a sequential process. First, we fetch the map directions, then we download the main imagery, and finally, we loop through every location in the tour to fetch its specific audio and background tracks. Once complete, we save the local file URIs back into SQLite so the app can run entirely without a network connection.
Step 4: Smart Switching with Custom Hooks
Once the data is saved locally, the app needs to know which source to prioritize. We use a custom hook, useOfflineDetector, which leverages useFocusEffect from React Navigation. This ensures that every time a user navigates to a tour page, we check the local SQLite database first.
import { useState, useCallback } from "react";import { useFocusEffect } from "@react-navigation/native";import { getDatabaseService } from "../../lib/sq-lite";export const useOfflineDetector = (tourID: number) => {const [isOfflineAvailable, setIsOfflineAvailable] = useState<boolean>(false);const fetchOfflineStatus = useCallback(async () => {const db = getDatabaseService();const offlineTour = await db.getOfflineData(tourID);// If we find data in SQLite, we flag it as offline-readysetIsOfflineAvailable(offlineTour !== null);}, [tourID]);// Re-run the check whenever the screen comes into focususeFocusEffect(useCallback(() => {fetchOfflineStatus();}, [fetchOfflineStatus]));return isOfflineAvailable;};
Handling data integrity on iOS updates
A common pitfall when building offline-first apps on iOS is the behavior of the Documents directory during app upgrades. While your SQLite database persists across updates, the files stored via expo-file-system in the documents directory can sometimes be purged or orphaned if the internal paths shift during a major version upgrade.
To prevent users from opening a "downloaded" tour only to find missing audio files, we implemented a Content Validation Bridge.
Step 1: Detecting the Update
We use expo-application to compare the current running version against the lastCheckedVersion stored in local storage. If they don't match, we trigger a validation sweep.
import * as Application from "expo-application";import { StorageKeys } from "../../types/storage.types";export const useVersionCheck = () => {const currentVersion = Application.nativeApplicationVersion;const versionCheck = useCallback(async () => {const lastChecked = await getLocalData(StorageKeys.LAST_CHECKED_VERSION);if (lastChecked?.version !== currentVersion) {// An update occurred! Re-verify our offline filesawait recheckDownloadedContent(language, voice, dispatch);// Update our trackerawait storeLocalData(StorageKeys.LAST_CHECKED_VERSION, { version: currentVersion });}}, [currentVersion]);};
Step 2: Validating the Filesystem
The recheckDownloadedContent function iterates through every tour marked as "offline" in SQLite and pings the filesystem to ensure the assets actually exist. If even one file is missing (like the main tour image or the first audio stop), we trigger a background re-download.
import { File } from "expo-file-system";export const checkMissingFiles = async (offlineTour: Tour): Promise<boolean> => {// Check the main header imageconst mainImage = new File(offlineTour.image.key);if (!mainImage.exists) return true;// Check every location-specific assetfor (const location of offlineTour.locations) {const imageExists = new File(location.image.key);const audioExists = new File(location.audioKey);if (!imageExists.exists || !audioExists.exists) return true;}return false;};
Powering Discovery with expo-location
Kuratour’s magic lies in its "Hands-Free" mode. We want travelers to keep their phones in their pockets and simply listen as stories trigger automatically based on their coordinates. To achieve this, we built a custom useLocation hook that manages permissions and sets up a high-accuracy background subscription.
Implementing the Location Watcher
By using Location.watchPositionAsync, we can update the app's state every few seconds or every time the user moves as little as one meter. This precision is vital for walking tours in dense city centers.
import * as Location from "expo-location";export const useLocation = (tourModeActive: boolean) => {const [location, setLocation] = useState<LatLng | null>(null);useEffect(() => {let locationSubscription: any;const watchLocation = async () => {// 1. Request foreground permissionsconst { status } = await Location.requestForegroundPermissionsAsync();if (status !== "granted") return;// 2. Subscribe to high-accuracy updateslocationSubscription = await Location.watchPositionAsync({accuracy: Location.Accuracy.High,timeInterval: 5000, // Update every 5 secondsdistanceInterval: 1, // Or every 1 meter},(newLocation) => {setLocation({latitude: newLocation.coords.latitude,longitude: newLocation.coords.longitude,bearing: newLocation.coords.heading || null,});});};if (tourModeActive) {watchLocation();}return () => {if (locationSubscription) locationSubscription.remove();};}, [tourModeActive]);return { location };};
Orchestrating Layered Sound with expo-audio
The "Kuratour experience" isn't just about reading text; it's about atmosphere. We use the modern expo-audio library to create a cinematic feel by layering location-based narration over a subtle ambient background track.
Unlike older audio implementations, expo-audio allows us to create multiple player instances: one for the story and one for the ambiance, while maintaining tight control over their volumes and sync.
Below is a simple example of using expo-audio
import { createAudioPlayer, AudioModule } from "expo-audio";export const TourAudioControls = ({ currentLocation, voice, language }) => {const [audioPlaying, setAudioPlaying] = useState(false);const [loadedSound, setLoadedSound] = useState<AudioPlayer | null>(null);const formatTime = (time: number) => {const minutes = Math.floor(time / 60000);const seconds = parseInt(((time % 60000) / 1000).toFixed(0));return minutes + ":" + (seconds < 10 ? "0" : "") + seconds;};const handleAudioState = async () => {// 1. Cleanup existing audio before loading a new stopif (loadedSound) {await loadedSound.pause();await loadedSound.remove(); // Essential for New Architecture memory management}try {// 2. Configure global audio behaviorawait AudioModule.setAudioModeAsync({interruptionMode: "duckOthers", // Narration ducks music/podcastsplaysInSilentMode: true,});// 3. Create the player with a local or remote URIconst player = createAudioPlayer({uri: getAudioUrl(currentLocation.audioKey)});// 4. Layer background ambiance at a lower volumeif (currentLocation.backgroundAudioKey) {const bgPlayer = createAudioPlayer({ uri: currentLocation.backgroundAudioKey });bgPlayer.volume = 0.2;bgPlayer.play();}setLoadedSound(player);player.play();setAudioPlaying(true);} catch (error) {console.error("Audio Load Error:", error);}};// 5. Track progress for the UI seek baruseEffect(() => {const interval = setInterval(() => {if (audioPlaying && loadedSound) {console.log(`Progress: ${loadedSound.currentTime} / ${loadedSound.duration}`);}}, 300);return () => clearInterval(interval);}, [audioPlaying, loadedSound]);// Simplified UI Renderreturn (<Animated.View style={[styles.container, { opacity: fadeAnim }]}><View style={styles.headerContainer}><Text style={styles.headerText}>{currentLocation.name}</Text><IconButton icon="close" onPress={closeAudio} /></View><View style={styles.audioControls}><IconButton icon="rewind" onPress={rewind5s} /><IconButtonicon={audioPlaying ? "pause" : "play"}size={36}onPress={togglePlayback}/><IconButton icon="fast-forward" onPress={fastForward5s} /></View><View style={styles.audioInfo}><Text>{formatTime(currentTime)}</Text><View style={styles.statusBar}><View style={{ ...styles.position, width: `${progress}%` }} /></View><Text>{formatTime(duration)}</Text></View></Animated.View>);};
AI-powered walking tour generation
One of Kuratour’s funnest features is its AI-assisted tour creation. Users can input any city name to generate a mapped route, commentary script, and audio narration.
- AI-generated text is converted into speech in multiple languages, and expo-audio is used for playback.
- Tours are stored as structured data and become instantly available for offline playback.
- The system supports 14 languages with male and female voice options.
Monitoring performance and stability
Kuratour uses @sentry/react-native to track errors and monitor performance in real time. Sentry dashboards help the team identify and fix device-specific issues quickly.
Scaling with tour operators
Kuratour’s technology now powers white-labeled apps for tour operators around the world, including:
Operators can access a custom CMS to manage tours, upload media, and publish new multilingual content in minutes, with no technical expertise required.
Leveraging Expo’s dynamic app.config.js and Expo's Services, we built a white-labeling system that allows us to generate a custom app for any operator in minutes
Step 1: The Operator Switch
We use a single environment variable to define which "flavor" of the app we are building.
# To build for a specific partnerEXPO_PUBLIC_OPERATOR_NAME="Partner Name"
Step 2: Automated Configuration Generation
Instead of maintaining dozens of static app.json files, we use a generate-config.js script. This script reads our environment variables and maps them to a centralized "Master Map" of project IDs, splash screen colors, and store URLs. It then programmatically writes the app.config.js and updates eas.json.
// generate-config.jsconst fs = require("fs");const operatorName = process.env.EXPO_PUBLIC_OPERATOR_NAME;const slug = operatorName.replace(/\s/g, "_").toLowerCase();const partnerBranding = {"Partner_A": { projectId: "...", splash: "#502FAE", icon: "icon_a.png" },"Partner_B": { projectId: "...", splash: "#00A99D", icon: "icon_b.png" },};const configContent = `export default {"expo": {"name": "${operatorName}","slug": "${slug}","icon": "./assets/branding/${slug}/icon.png","ios": { "bundleIdentifier": "com.${slug}.ios.app" },"android": { "package": "com.${slug}.android.app" },"plugins": [["expo-splash-screen", {"backgroundColor": "${partnerBranding[slug].splash}","image": "./assets/branding/${slug}/logo.png"}]],"extra": { "eas": { "projectId": "${partnerBranding[slug].projectId}" } }}}`;fs.writeFileSync("app.config.js", configContent);
Step 3: Dynamic branding with a theme engine
Once the generate-config.js script sets the operatorName at the build level, we use a centralized theme.ts file to drive the look and feel of the entire application. By mapping the operator name to a specific configuration object, we can swap out everything from primary brand colors to specific category icons (like "sightseeing" vs. "accommodation") without touching a single component.
import { DefaultTheme } from "react-native-paper";import { getConfig } from "../config/app.config";// 1. Grab the name injected during the build processconst operatorName = getConfig().operatorName.toLowerCase();export const operatorThemes: Record<string, any> = {"default operator": {logo: require("./images/default/logo_primary.png"),colours: {primary: "#204074",secondary: "#2b569c",locations: {sightseeing: "#204074","food and drink": "#FFC107",activity: "#2196F3",// ...},},},"premium partner": {logo: require("./images/premium_partner/logo.png"),colours: {primary: "#502FAE",secondary: "#7CE9BD",locations: {sightseeing: "#502FAE","food and drink": "#F2B705",activity: "#00C4B8",// ...},},},};// 2. Export the active theme for use in StyleSheet and Providersexport const operatorTheme = operatorThemes[operatorName] || operatorThemes["default operator"];export const operatorColours = operatorTheme.colours;export const theme = {...DefaultTheme,colors: {...DefaultTheme.colors,...operatorColours,},};
The benefits of this approach
- Single Codebase, Infinite Flavors: We maintain one set of high-quality components. If we improve the audio player or the map view, every white-label partner gets that upgrade instantly.
- Asset Bundling: Since the logos are
require()'d based on the build config, only the assets relevant to that specific operator are bundled into the final binary. - Hybrid Data Strategy: While the visual identity is baked in at build time for speed and offline reliability, the tour content itself—scripts, routes, and coordinates—is fetched from our PostgreSQL database via a Go API. This allows us to update tour details in real-time without requiring a new app store submission.
What’s next?
We plan to expand Kuratour’s Walking and Driving Tours across the world. And we are going to add “My Tour Experience” for tour operators, which will allow them to ditch WhatsApp and manage tours and communication in their own custom app and backend.
We are also thinking of adding a portal for travel agents so they can offer Kuratour to their clients via commissionable sales.
Conclusion
Kuratour is a testament to what is possible when you combine Expo’s modern development stack, Expo’s production-grade infrastructure, and AI-driven personalization. By prioritizing an offline-first architecture and a layered audio engine, we’ve built more than just a travel app; we’ve created a robust platform for location-aware storytelling that scales across 14 languages and dozens of white-label partners.
For developers, this project should re-emphasize that Expo is the platform for building powerful applications. With the new expo-audio module, high-performance expo-location tracking, and the reliability of SQLite, you can build complex, media-rich applications that thrive in the most challenging real-world environments.
Experience Kuratour yourself
Whether you are a developer looking for inspiration or a traveler ready for your next adventure, we invite you to see the tech in action. Explore over 100 destinations with GPS-triggered stories, offline maps, and AI-generated routes tailored just for you.





