Une appli « cinémas autour de moi » en React Native avec Expo

Construire une appli Expo Router qui trouve les cinémas autour de l’utilisateur et affiche le programme du jour avec les liens de réservation, via un petit relais qui garde la clé API hors du téléphone.

8 min de lecture

Sur cette page
  1. Un relais, pas de clé dans l’appli
  2. Ce qu’il vous faut
  3. 1.Le relais
  4. 2.Créer l’appli
  5. 3.Le client du relais
  6. 4.Les écrans
  7. 5.Les cinémas autour de moi
  8. 6.Le programme du jour
  9. 7.Lancer l’appli
  10. Passer sur les stores
  11. Combien de requêtes ça coûte ?
  12. Pour aller plus loin

« Cinémas autour de moi » est le premier écran de toute appli de cinéma : le téléphone donne sa position, l’appli liste les cinémas les plus proches, un appui montre le programme du jour. Ce tutoriel le construit avec React Native et Expo (SDK 58, Expo Router), en deux écrans, pour la France et les États-Unis.

Cinémas autour de moi               ← Max Linder
[ Trouver les cinémas autour de moi ]   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

Un relais, pas de clé dans l’appli

Tout ce qui est livré dans une appli mobile peut être lu : une clé dans le bundle JavaScript, dans une variable EXPO_PUBLIC_ ou dans la config de l’appli s’extrait en quelques minutes, et n’importe qui peut alors consommer votre quota. L’appli n’appelle donc jamais API Cinéma directement. Elle appelle votre relais, un serveur d’environ 90 lignes qui garde la clé, ne répond qu’aux deux questions que pose l’appli, garde les réponses en cache et limite chaque téléphone.

téléphone (Expo) ──GET /cinemas?lat&lng──▶ votre relais ──clé Bearer──▶ API Cinéma
   (aucune clé)   ──GET /cinemas/:slug/showtimes──▶  (la clé, un cache, une limite)

Le relais met aussi les réponses en forme pour les écrans : les cinémas les plus proches avec leur distance, et le programme regroupé par film. L’appli reste simple et télécharge moins.

Ce qu’il vous faut

  • Node.js 20 ou plus récent.
  • L’appli Expo Go sur votre téléphone (App Store ou Google Play), ou un simulateur iOS / émulateur Android.
  • Une clé API Cinéma : créez un compte gratuit, confirmez votre adresse e-mail, puis créez une clé depuis le tableau de bord. L’offre gratuite donne 150 requêtes par semaine, de quoi construire et tester ; une appli publiée sur les stores passe à Starter (12 € par mois, 10 000 requêtes par semaine) ou Pro (49 €, 100 000).

Le relais : 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 manquante')

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

/** Un GET sur l’API avec la clé, en cache 15 minutes : l’appli ne voit jamais la clé. */
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 a répondu ${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 appels par minute et par téléphone au plus : une boucle dans l’appli ou un script ne vide pas le 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()

// seule la version web de l’appli en a besoin (npx expo start --web) ; iOS et Android ignorent CORS
app.use('*', cors({ origin: process.env.WEB_ORIGIN ?? 'http://localhost:8081' }))

app.use('*', async (c, next) => {
  // derrière un proxy (Fly, Render, Nginx), la première adresse de x-forwarded-for est le téléphone
  const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'local'
  if (tooMany(ip))
    return c.json({ error: 'Trop de requêtes, patientez une minute.' }, 429)
  await next()
})

// les cinémas à moins de 15 km, du plus proche au plus loin
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 et lng sont obligatoires' }, 400)
  // arrondie à 1 km environ : les voisins partagent le cache, et la position exacte ne quitte pas le serveur
  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 })))
})

// le programme du jour d’un cinéma, une entrée par film
app.get('/cinemas/:slug/showtimes', async (c) => {
  const slug = c.req.param('slug')
  if (!/^[\w-]{1,100}$/.test(slug))
    return c.json({ error: 'cinéma invalide' }, 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 est déjà l’heure locale du cinéma
    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: 'Séances indisponibles, réessayez plus tard.' }, 502)
})

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

Essayez depuis un autre terminal : curl "http://localhost:8787/cinemas?lat=48.8708&lng=2.3317" liste les cinémas autour de l’Opéra à Paris, curl http://localhost:8787/cinemas/max-linder-paris-9e-arrondissement/showtimes le programme de l’un d’eux.

Trois détails comptent. La position est arrondie à 1 km environ avant de quitter votre serveur : les voisins partagent le cache, et API Cinéma ne reçoit jamais la position exacte de vos utilisateurs. Le slug du cinéma est vérifié par un motif strict : l’appli ne peut pas faire appeler une autre route au relais avec votre clé. Et les erreurs de l’API sont journalisées, jamais renvoyées telles quelles.

Créer l’appli

Terminal
npx create-expo-app@latest cinemas
cd cinemas
npm run reset-project        # répondre n : supprime les écrans d’exemple
npx expo install expo-location

Dans app.json, ajoutez à la liste plugins la phrase qu’iOS affiche en demandant la position :

app.json
["expo-location", { "locationWhenInUsePermission": "Votre position sert à trouver les cinémas autour de vous." }]

L’adresse du relais va dans .env. Ce n’est pas un secret, c’est pourquoi elle peut être une variable EXPO_PUBLIC_ : celles-ci sont écrites dans le bundle de l’appli. Sur un vrai téléphone, localhost désigne le téléphone lui-même : mettez l’adresse de votre ordinateur sur le réseau Wi-Fi.

.env
EXPO_PUBLIC_RELAY_URL=http://192.168.1.20:8787

Le client du relais : src/lib/relay.ts

src/lib/relay.ts
// l’adresse de votre relais, pas un secret : les variables EXPO_PUBLIC_ finissent dans le bundle de l’appli
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 ?? `Erreur ${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`)

Les écrans : src/app/_layout.tsx

Expo Router fait de chaque fichier de src/app un écran. Une pile : la liste, puis le programme par-dessus.

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

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="index" options={{ title: 'Cinémas autour de moi' }} />
      <Stack.Screen name="cinema/[slug]" options={{ title: 'Aujourd’hui' }} />
    </Stack>
  )
}

Les cinémas autour de moi : src/app/index.tsx

La position est demandée quand l’utilisateur appuie sur le bouton, pas au lancement : une autorisation demandée en contexte est bien plus souvent accordée, et un refus donne un message clair au lieu d’un écran vide.

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('Localisation désactivée : autorisez-la dans les réglages pour voir les cinémas autour de vous.')
      // Balanced suffit pour « à moins de 15 km » et économise la batterie
      const { coords } = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced })
      setCinemas(await nearbyCinemas(coords.latitude, coords.longitude))
    }
    catch (e) {
      setError(e instanceof Error ? e.message : 'Une erreur est survenue.')
    }
    finally {
      setLoading(false)
    }
  }

  return (
    <View style={styles.screen}>
      <Pressable style={styles.button} onPress={locate} disabled={loading}>
        <Text style={styles.buttonText}>{cinemas ? 'Actualiser' : 'Trouver les cinémas autour de moi'}</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}>Aucun cinéma à moins de 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 suffit pour trouver les cinémas à moins de 15 km : la réponse arrive plus vite qu’avec le GPS et consomme moins de batterie.

Le programme du jour : src/app/cinema/[slug].tsx

Le slug vient de la route, le nom du cinéma du lien (il donne son titre à l’écran sans autre requête). Chaque horaire ouvre la page de réservation du cinéma.

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 ?? 'Aujourd’hui' }} />
      {!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}>Plus de séance aujourd’hui.</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' },
})

Les horaires sont à l’heure locale du cinéma, comme l’API les renvoie : un utilisateur à Lyon qui regarde un cinéma de New York voit l’heure de New York, celle dont il a besoin pour y aller.

Lancer l’appli

Terminal
npx expo start

Scannez le QR code avec Expo Go (Android) ou l’appareil photo (iOS), appuyez sur Trouver les cinémas autour de moi, autorisez la position, puis ouvrez un cinéma. expo-location fait partie d’Expo Go : pas besoin de build natif pour essayer. La touche w ouvre la même appli dans un navigateur, que le relais accepte depuis http://localhost:8081.

Passer sur les stores

  • Déployez le relais sur n’importe quel hébergeur Node (Render, Fly.io, un VPS) avec API_CINEMA_KEY dans ses secrets, en HTTPS : les builds iOS et Android de production refusent le HTTP simple. Mettez ensuite son adresse dans EXPO_PUBLIC_RELAY_URL et lancez le build avec npx eas-cli@latest build.
  • Une clé par environnement. Une clé pour le développement, une autre pour le relais en production : vous pouvez révoquer l’une depuis le tableau de bord sans couper l’appli.
  • Gardez la limite. 30 appels par minute et par adresse, c’est large pour une personne et ça arrête un script. Si votre relais est derrière un proxy, vérifiez que x-forwarded-for vient de lui et pas du client.

Combien de requêtes ça coûte ?

Une requête par écran que le cache ne sert pas : un utilisateur qui ouvre la liste et deux cinémas coûte trois requêtes au plus, beaucoup moins dans un quartier animé où les voisins partagent le cache pendant 15 minutes. Avec les 10 000 requêtes par semaine de Starter, cela fait quelques milliers de sessions par semaine ; Pro va dix fois plus loin.

Pour aller plus loin

  • Un autre jour. Ajoutez un paramètre date (AAAA-MM-JJ) à la route du programme dans le relais, et une rangée de jours au-dessus de la liste ; l’API renvoie le programme de n’importe quel jour à venir.
  • Les films autour de moi. Une troisième route sur /v1/showtimes avec near, radius_km et date liste toutes les séances autour de l’utilisateur, pour un écran « ce soir près de chez moi » trié par heure.
  • Version originale seulement. L’API filtre avec version=VO ou VOST : un interrupteur sur l’écran du programme, transmis par le relais.

Tous les paramètres et champs sont dans la documentation. La même API sert au tutoriel Next.js pour les sites web et au tutoriel chatbot, dont la partie sur la protection de la clé vaut aussi pour le relais.