The offline first, multilingual audio tour app built with Expo

UsersReact NativeDevelopment11 minutes read

Clinton Forster

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.

The offline first, multilingual audio tour app built with Expo

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:

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-location ensure 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.

Code
import * as SQLite from "expo-sqlite";
export class DatabaseService {
private db: SQLite.SQLiteDatabase;
private currentVersion = 3;
constructor() {
// Open (or create) the database
this.db = SQLite.openDatabaseSync("db");
this.initializeSchema();
}
private initializeSchema() {
try {
// 1. create schema table if it doesn't exist
this.db.execSync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY NOT NULL
);
`);
// check db version and apply appropriate migrations
const 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 retrieval
async 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.

Code
import { Directory, File, Paths } from "expo-file-system";
const downloadFile = async (key: string) => {
const CDNUrl = getUrl(key); // Your CDN/Storage URL to the file
const 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 exists
if (!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 filesystem
await 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.

Code
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-ready
setIsOfflineAvailable(offlineTour !== null);
}, [tourID]);
// Re-run the check whenever the screen comes into focus
useFocusEffect(
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.

Code
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 files
await recheckDownloadedContent(language, voice, dispatch);
// Update our tracker
await 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.

Code
import { File } from "expo-file-system";
export const checkMissingFiles = async (offlineTour: Tour): Promise<boolean> => {
// Check the main header image
const mainImage = new File(offlineTour.image.key);
if (!mainImage.exists) return true;
// Check every location-specific asset
for (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.

Code
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 permissions
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return;
// 2. Subscribe to high-accuracy updates
locationSubscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
timeInterval: 5000, // Update every 5 seconds
distanceInterval: 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

Code
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 stop
if (loadedSound) {
await loadedSound.pause();
await loadedSound.remove(); // Essential for New Architecture memory management
}
try {
// 2. Configure global audio behavior
await AudioModule.setAudioModeAsync({
interruptionMode: "duckOthers", // Narration ducks music/podcasts
playsInSilentMode: true,
});
// 3. Create the player with a local or remote URI
const player = createAudioPlayer({
uri: getAudioUrl(currentLocation.audioKey)
});
// 4. Layer background ambiance at a lower volume
if (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 bar
useEffect(() => {
const interval = setInterval(() => {
if (audioPlaying && loadedSound) {
console.log(`Progress: ${loadedSound.currentTime} / ${loadedSound.duration}`);
}
}, 300);
return () => clearInterval(interval);
}, [audioPlaying, loadedSound]);
// Simplified UI Render
return (
<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} />
<IconButton
icon={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.

Code
# To build for a specific partner
EXPO_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.

Code
// generate-config.js
const 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.

Code
import { DefaultTheme } from "react-native-paper";
import { getConfig } from "../config/app.config";
// 1. Grab the name injected during the build process
const 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 Providers
export 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.

Download Kuratour today

Offline first
React Native
Expo Camera

Accelerate building apps with Expo and AI

Learn more