Step 1 — Install and declare the reason
npx expo install expo-locationThe permission string is not boilerplate — iOS shows it verbatim in the system prompt, and App Review rejects vague ones. Write the sentence you would want to read:
{
"expo": {
"plugins": [
[
"expo-location",
{
"locationWhenInUsePermission":
"We use your location to show jobs near you and pre-fill your address."
}
]
]
}
}Compare that with “This app needs access to your location.” — which tells the user nothing, converts worse, and is exactly the phrasing that gets flagged. Name the feature and the benefit.
Step 2 — Ask late, not at launch
The most expensive mistake in this whole guide is asking for location on the first screen. The user has no context, denies it, and iOS will not re-prompt — from then on your only recourse is sending them into Settings, which almost nobody does. Ask the moment they tap something that obviously needs it.
import { useState } from 'react';
import { Button, Linking, Text, View } from 'react-native';
import * as Location from 'expo-location';
export default function NearbyScreen() {
const [status, requestPermission] = Location.useForegroundPermissions();
const [coords, setCoords] = useState(null);
const [loading, setLoading] = useState(false);
const findNearby = async () => {
const granted = status?.granted
? status
: await requestPermission(); // prompts only if not yet decided
if (!granted.granted) return; // denied — the UI below handles it
setLoading(true);
// instant, possibly stale — render something immediately
const cached = await Location.getLastKnownPositionAsync();
if (cached) setCoords(cached.coords);
const fresh = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced,
});
setCoords(fresh.coords);
setLoading(false);
};
if (status && !status.granted && !status.canAskAgain) {
return (
<View style={{ padding: 24, gap: 12 }}>
<Text style={{ color: '#fff' }}>
Location is off for this app. You can turn it on in Settings, or enter
your area manually.
</Text>
<Button title="Open Settings" onPress={() => Linking.openSettings()} />
<Button title="Enter it manually" onPress={/* … */ undefined} />
</View>
);
}
return (
<View style={{ padding: 24, gap: 12 }}>
<Button
title={loading ? 'Locating…' : 'Find jobs near me'}
onPress={findNearby}
disabled={loading}
/>
{coords && (
<Text style={{ color: '#fff' }}>
{coords.latitude.toFixed(4)}, {coords.longitude.toFixed(4)}
</Text>
)}
</View>
);
}Two details do most of the work here. getLastKnownPositionAsync returns a cached fix immediately, so the screen has something to show while the real fix is acquired — without it, a cold GPS lock indoors can leave a spinner up for several seconds and read as a hang. And canAskAgainis what distinguishes “not asked yet” from “permanently denied”; only in the second case is the Settings link the right thing to show.
Step 3 — Pick an accuracy tier on purpose
Accuracy is a battery dial, and most apps leave it turned up for no reason. Roughly:
| Accuracy | Roughly | Battery cost | Good for |
|---|---|---|---|
Lowest / Low | ~1km – 3km | Minimal | City-level content, weather, currency |
Balanced | ~100m | Low | Nearby listings, "restaurants around me" |
High | ~10m | Noticeable | Map pin, delivery address confirm |
Highest / BestForNavigation | Best available | Heavy | Turn-by-turn, run tracking |
If the feature is “show me what’s nearby”, Balanced is correct and noticeably faster to a first fix. Reserve BestForNavigation for things that genuinely follow a moving person.
Live tracking while the screen is open
For a run tracker or a driver view you want a stream, not a reading. The two things that matter are the throttles and the cleanup:
import { useEffect, useRef, useState } from 'react';
import * as Location from 'expo-location';
export function useLiveLocation(enabled) {
const [path, setPath] = useState([]);
const subRef = useRef(null);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
(async () => {
const { granted } = await Location.requestForegroundPermissionsAsync();
if (!granted || cancelled) return;
subRef.current = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High,
distanceInterval: 10, // metres moved before an update
timeInterval: 5000, // and no more often than this
},
({ coords }) => setPath((prev) => [...prev, coords]),
);
})();
return () => {
cancelled = true;
subRef.current?.remove(); // ← forget this and the GPS stays on
subRef.current = null;
};
}, [enabled]);
return path;
}subRef.current?.remove()in the cleanup is not optional. A watch that outlives its screen keeps the GPS radio active for the rest of the session, and shows up in the user’s battery report with your app’s name next to it. The cancelled flag covers the case where the screen unmounts while the permission prompt is still open.
Background location: think twice
Tracking location while the app is closed is a different feature with a different cost. It needs expo-task-manager, a separate Always permission with its own usage string, background mode declarations, and — in practice — a written justification when the store reviews it. Both stores treat it as a privacy-sensitive capability and they are right to.
Before you go there, check whether you actually need it. “Track my run” usually means “the screen is on or the app is in the foreground”, which a foreground watch handles. Geofenced reminders and delivery-driver apps are the real cases. If you are adding background tracking because it seemed like the complete version of the feature, you are buying a review risk and a battery complaint for nothing.
Coordinates to something a human reads
A latitude and longitude is rarely what you want on screen. expo-location includes reverse geocoding:
const [place] = await Location.reverseGeocodeAsync({
latitude: coords.latitude,
longitude: coords.longitude,
});
// place → { city, region, postalCode, street, country, … }
setLabel([place.city, place.region].filter(Boolean).join(', '));Fields vary by country — a result with no street or no cityis normal, not an error, so filter before joining rather than interpolating blindly and shipping “undefined, undefined” to a user abroad. From here the natural next step is putting the position on a map, which the maps integration guide covers.
The shortcut
Location is a feature where the boilerplate — permission state, cached-then-fresh reads, denial handling, subscription cleanup — is longer than the part that makes your app yours. Describe the behaviour (“show jobs near me, ask for location only when I tap it, let me type a postcode instead”) and ShipNative generates the permission flow, the fallback UI, and the plugin config as a real Expo project you can preview on your phone and export in full.