A “cinemas near me” app in React Native with Expo

Build an Expo Router app that finds the cinemas around the user and shows today’s programme with booking links, through a small relay that keeps your API key off the phone. France and US.

8 min read

On this page
  1. A relay, not a key in the app
  2. What you need
  3. 1.The relay
  4. 2.Create the app
  5. 3.The relay client
  6. 4.The screens
  7. 5.Cinemas near me
  8. 6.Today's programme
  9. 7.Run it
  10. Going to the stores
  11. How many requests does it cost?
  12. Going further

"Cinemas near me" is the first screen of any film app: the phone gives its position, the app lists the nearest cinemas, a tap shows today's programme. This tutorial builds it with React Native and Expo (SDK 58, Expo Router), in two screens, for France and the United States.

Cinemas near me                     ← Max Linder
[ Find cinemas around me ]          Moulin · 2 h 10
Pathé Palace     0.3 km             [11:00 VF] [13:30 VF] [16:00 VF] …
UGC Opéra        0.4 km
Max Linder       1.1 km

A relay, not a key in the app

Everything shipped in a mobile app can be read: a key in the JavaScript bundle, in an EXPO_PUBLIC_ variable or in the app config is extracted in minutes, and then anyone can spend your quota. So the app never calls API Cinéma directly. It calls your relay, a server of about 90 lines that holds the key, answers only the two questions the app asks, keeps answers in cache and limits each phone.

phone (Expo) ──GET /cinemas?lat&lng──▶ your relay ──Bearer key──▶ API Cinéma
   (no key)   ──GET /cinemas/:slug/showtimes──▶  (the key, a cache, a limit)

The relay also shapes the answers for the screens: the nearest cinemas with their distance, and the programme grouped by film. The app stays simple and downloads less.

What you need

  • Node.js 20 or later.
  • The Expo Go app on your phone (App Store or Google Play), or an iOS simulator / Android emulator.
  • An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan gives 150 requests a week, enough to build and test; an app in the stores moves to Starter (€12 a month, 10,000 requests a week) or Pro (€49, 100,000).

The relay: relay/server.ts

Terminal
mkdir relay && cd relay
npm init -y && npm pkg set type=module
npm install hono @hono/node-server
npm install -D tsx
relay/server.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'

const API = 'https://api.api-cinema.com/v1'
const KEY = process.env.API_CINEMA_KEY
if (!KEY)
  throw new Error('API_CINEMA_KEY is missing')

const cache = new Map<string, { at: number, body: unknown }>()
const TTL = 15 * 60_000

/** One GET on the API with the key, cached 15 minutes: the app never sees the key. */
async function apiGet<T>(path: string, params: Record<string, string | number | undefined>): Promise<T> {
  const query = new URLSearchParams()
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== '')
      query.set(k, String(v))
  }
  const url = `${API}${path}?${query}`
  const hit = cache.get(url)
  if (hit && Date.now() - hit.at < TTL)
    return hit.body as T
  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` }, signal: AbortSignal.timeout(10_000) })
  if (!res.ok)
    throw new Error(`API Cinéma answered ${res.status}`)
  const body = await res.json()
  cache.set(url, { at: Date.now(), body })
  return body as T
}

const hits = new Map<string, number[]>()
/** 30 calls a minute per phone at most: a loop in the app or a script cannot drain the quota. */
function tooMany(ip: string): boolean {
  const now = Date.now()
  const recent = (hits.get(ip) ?? []).filter(t => now - t < 60_000)
  recent.push(now)
  hits.set(ip, recent)
  return recent.length > 30
}

interface Cinema { slug: string, name: string, address: string | null, city: string, distanceKm: number }
interface Showtime { startsAt: string, version: string, format: string, bookingUrl: string | null, movie: { id: string, title: string, durationMin: number | null, posterUrl: string | null } }

const app = new Hono()

// only the web build of the app needs it (npx expo start --web); iOS and Android ignore CORS
app.use('*', cors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8081' }))

app.use('*', async (c, next) => {
  // behind a proxy (Fly, Render, Nginx), the first x-forwarded-for address is the phone
  const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'local'
  if (tooMany(ip))
    return c.json({ error: 'Too many requests, wait a minute.' }, 429)
  await next()
})

// cinemas within 15 km, nearest first
app.get('/cinemas', async (c) => {
  const lat = Number(c.req.query('lat'))
  const lng = Number(c.req.query('lng'))
  if (!(Math.abs(lat) <= 90 && Math.abs(lng) <= 180))
    return c.json({ error: 'lat and lng are required' }, 400)
  // rounded to about 1 km: neighbours share the cache, and the exact position never leaves the server
  const near = `${lat.toFixed(2)},${lng.toFixed(2)}`
  const { data } = await apiGet<{ data: Cinema[] }>('/cinemas', { near, radius_km: 15, limit: 30 })
  return c.json(data.map(({ slug, name, address, city, distanceKm }) => ({ slug, name, address, city, distanceKm })))
})

// today's programme of one cinema, one entry per film
app.get('/cinemas/:slug/showtimes', async (c) => {
  const slug = c.req.param('slug')
  if (!/^[\w-]{1,100}$/.test(slug))
    return c.json({ error: 'bad cinema' }, 400)
  const { data } = await apiGet<{ data: Showtime[] }>(`/cinemas/${slug}/showtimes`, { include: 'movie', limit: 300 })
  const films = new Map<string, { title: string, durationMin: number | null, posterUrl: string | null, times: { time: string, version: string, format: string, bookingUrl: string | null }[] }>()
  for (const s of data) {
    const film = films.get(s.movie.id) ?? { title: s.movie.title, durationMin: s.movie.durationMin, posterUrl: s.movie.posterUrl, times: [] }
    // startsAt is already the cinema's local time
    film.times.push({ time: s.startsAt.slice(11, 16), version: s.version, format: s.format, bookingUrl: s.bookingUrl })
    films.set(s.movie.id, film)
  }
  return c.json([...films.values()])
})

app.onError((err, c) => {
  console.error(err.message)
  return c.json({ error: 'Showtimes unavailable, try again later.' }, 502)
})

serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 8787) })
Terminal
API_CINEMA_KEY=ac_live_… npx tsx server.ts

Try it from another terminal: curl "http://localhost:8787/cinemas?lat=48.8708&lng=2.3317" lists the cinemas around the Opéra in Paris, curl http://localhost:8787/cinemas/max-linder-paris-9e-arrondissement/showtimes the programme of one of them.

Three details matter. The position is rounded to about 1 km before it leaves your server: neighbours share the cache, and API Cinéma never receives the exact location of your users. The cinema slug is checked against a strict pattern, so the app cannot make the relay call any other route with your key. And errors from the API are logged, never sent back as they are.

Create the app

Terminal
npx create-expo-app@latest cinemas
cd cinemas
npm run reset-project        # answer n: removes the example screens
npx expo install expo-location

In app.json, add the sentence iOS shows when it asks for the location, in the plugins list:

app.json
["expo-location", { "locationWhenInUsePermission": "Your location finds the cinemas around you." }]

The address of the relay goes in .env. It is not a secret, which is why it can be an EXPO_PUBLIC_ variable: those are written into the app bundle. On a real phone, localhost is the phone itself: use your computer's address on the Wi-Fi network.

.env
EXPO_PUBLIC_RELAY_URL=http://192.168.1.20:8787

The relay client: src/lib/relay.ts

src/lib/relay.ts
// the address of your relay, not a secret: EXPO_PUBLIC_ variables end up in the app bundle
const RELAY = process.env.EXPO_PUBLIC_RELAY_URL ?? 'http://localhost:8787'

export interface Cinema { slug: string, name: string, address: string | null, city: string, distanceKm: number }
export interface Showtime { time: string, version: string, format: string, bookingUrl: string | null }
export interface Film { title: string, durationMin: number | null, posterUrl: string | null, times: Showtime[] }

async function get<T>(path: string): Promise<T> {
  const res = await fetch(`${RELAY}${path}`)
  const body = await res.json()
  if (!res.ok)
    throw new Error(body.error ?? `Error ${res.status}`)
  return body as T
}

export const nearbyCinemas = (lat: number, lng: number) => get<Cinema[]>(`/cinemas?lat=${lat}&lng=${lng}`)
export const cinemaProgramme = (slug: string) => get<Film[]>(`/cinemas/${encodeURIComponent(slug)}/showtimes`)

The screens: src/app/_layout.tsx

Expo Router turns each file of src/app into a screen. A stack: the list, then the programme on top.

src/app/_layout.tsx
import { Stack } from 'expo-router'

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Cinemas near me' }} />
      <Stack.Screen name="cinema/[slug]" options={{ title: 'Today' }} />
    </Stack>
  )
}

Cinemas near me: src/app/index.tsx

The location is asked when the user taps the button, not at launch: a permission asked in context is granted far more often, and a refusal gets a clear message instead of an empty screen.

src/app/index.tsx
import type { Cinema } from '@/lib/relay'
import * as Location from 'expo-location'
import { Link } from 'expo-router'
import { useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { nearbyCinemas } from '@/lib/relay'

export default function NearbyCinemas() {
  const [cinemas, setCinemas] = useState<Cinema[] | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function locate() {
    setLoading(true)
    setError(null)
    try {
      const { status } = await Location.requestForegroundPermissionsAsync()
      if (status !== 'granted')
        throw new Error('Location is off: allow it in the settings to see the cinemas around you.')
      // Balanced is enough for "within 15 km" and saves battery
      const { coords } = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced })
      setCinemas(await nearbyCinemas(coords.latitude, coords.longitude))
    }
    catch (e) {
      setError(e instanceof Error ? e.message : 'Something went wrong.')
    }
    finally {
      setLoading(false)
    }
  }

  return (
    <View style={styles.screen}>
      <Pressable style={styles.button} onPress={locate} disabled={loading}>
        <Text style={styles.buttonText}>{cinemas ? 'Refresh' : 'Find cinemas around me'}</Text>
      </Pressable>
      {loading && <ActivityIndicator style={styles.gap} />}
      {error && <Text style={styles.error}>{error}</Text>}
      <FlatList
        data={cinemas ?? []}
        keyExtractor={c => c.slug}
        ListEmptyComponent={cinemas ? <Text style={styles.gap}>No cinema within 15 km.</Text> : undefined}
        renderItem={({ item }) => (
          <Link href={{ pathname: '/cinema/[slug]', params: { slug: item.slug, name: item.name } }} asChild>
            <Pressable style={styles.row}>
              <Text style={styles.name}>{item.name}</Text>
              <Text style={styles.meta}>{item.distanceKm.toFixed(1)} km · {item.address ? `${item.address}, ` : ''}{item.city}</Text>
            </Pressable>
          </Link>
        )}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, backgroundColor: '#fff' },
  button: { backgroundColor: '#111', borderRadius: 10, padding: 14, alignItems: 'center' },
  buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
  gap: { marginTop: 16, textAlign: 'center' },
  error: { marginTop: 16, color: '#b42318' },
  row: { paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ccc' },
  name: { fontSize: 17, fontWeight: '600' },
  meta: { marginTop: 2, color: '#666' },
})

Accuracy.Balanced is enough to find the cinemas within 15 km: it answers faster than GPS and uses less battery.

Today's programme: src/app/cinema/[slug].tsx

The slug comes from the route, the cinema's name from the link (it titles the screen without another request). Each time opens the cinema's booking page.

src/app/cinema/[slug].tsx
import type { Film } from '@/lib/relay'
import { Image } from 'expo-image'
import { Stack, useLocalSearchParams } from 'expo-router'
import { useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { cinemaProgramme } from '@/lib/relay'

export default function CinemaProgramme() {
  const { slug, name } = useLocalSearchParams<{ slug: string, name?: string }>()
  const [films, setFilms] = useState<Film[] | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    cinemaProgramme(slug).then(setFilms, e => setError(e.message))
  }, [slug])

  return (
    <View style={styles.screen}>
      <Stack.Screen options={{ title: name ?? 'Today' }} />
      {!films && !error && <ActivityIndicator style={styles.gap} />}
      {error && <Text style={styles.error}>{error}</Text>}
      <FlatList
        data={films ?? []}
        keyExtractor={f => f.title}
        ListEmptyComponent={films ? <Text style={styles.gap}>No more showtimes today.</Text> : undefined}
        renderItem={({ item }) => (
          <View style={styles.film}>
            {item.posterUrl
              ? <Image source={item.posterUrl} style={styles.poster} contentFit="cover" />
              : <View style={styles.poster} />}
            <View style={styles.body}>
              <Text style={styles.title}>{item.title}</Text>
              {item.durationMin ? <Text style={styles.meta}>{Math.floor(item.durationMin / 60)} h {String(item.durationMin % 60).padStart(2, '0')}</Text> : undefined}
              <View style={styles.times}>
                {item.times.map(t => (
                  <Pressable
                    key={`${t.time}-${t.version}-${t.format}`}
                    style={styles.time}
                    disabled={!t.bookingUrl}
                    onPress={() => t.bookingUrl && Linking.openURL(t.bookingUrl)}
                  >
                    <Text style={styles.timeText}>{t.time}</Text>
                    <Text style={styles.version}>{t.version}{t.format !== '2D' ? ` ${t.format}` : ''}</Text>
                  </Pressable>
                ))}
              </View>
            </View>
          </View>
        )}
      />
    </View>
  )
}

const styles = StyleSheet.create({
  screen: { flex: 1, paddingHorizontal: 16, backgroundColor: '#fff' },
  gap: { marginTop: 16, textAlign: 'center' },
  error: { marginTop: 16, color: '#b42318' },
  film: { flexDirection: 'row', gap: 12, paddingVertical: 14, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#ccc' },
  poster: { width: 64, height: 96, borderRadius: 6, backgroundColor: '#eee' },
  body: { flex: 1 },
  title: { fontSize: 16, fontWeight: '600' },
  meta: { marginTop: 2, color: '#666' },
  times: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 8 },
  time: { borderWidth: 1, borderColor: '#111', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 10, alignItems: 'center' },
  timeText: { fontWeight: '600' },
  version: { fontSize: 11, color: '#666' },
})

Times are the cinema's local time, as the API returns them: a user in Lyon who looks at a cinema in New York sees New York times, which is what they need to go there.

Run it

Terminal
npx expo start

Scan the QR code with Expo Go (Android) or the camera (iOS), tap Find cinemas around me, allow the location, then open a cinema. expo-location is part of Expo Go: no native build is needed to try it. Press w to open the same app in a browser, which the relay accepts from http://localhost:8081.

Going to the stores

  • Deploy the relay on any Node host (Render, Fly.io, a VPS) with API_CINEMA_KEY in its secrets, over HTTPS: production iOS and Android builds refuse plain HTTP. Then set EXPO_PUBLIC_RELAY_URL to its address and build with npx eas-cli@latest build.
  • One key per environment. A key for development, another for the relay in production: you can revoke one from the dashboard without cutting the app.
  • Keep the limit. The 30 calls a minute per address is generous for a person and stops a script. If your relay sits behind a proxy, make sure x-forwarded-for comes from it and not from the client.

How many requests does it cost?

One request per screen the cache cannot answer: a user who opens the list and two cinemas costs at most three requests, and much less in a busy area where neighbours share the cache for 15 minutes. With Starter's 10,000 requests a week, that is a few thousand sessions a week; Pro goes ten times further.

Going further

  • Another day. Add a date parameter (YYYY-MM-DD) to the programme route of the relay and a row of days above the list; the API returns the programme of any upcoming day.
  • Films near me. A third route on /v1/showtimes with near, radius_km and date lists every showtime around the user, to build a "what's on tonight" screen sorted by start time.
  • Original version only. The API filters with version=VO or VOST: a switch on the programme screen, passed through the relay.

Every parameter and field is in the documentation. The same API powers the Next.js tutorial for websites and the chatbot tutorial, whose section on protecting the key applies to the relay too.