Sur cette page
« 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 kmUn 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
mkdir relay && cd relay
npm init -y && npm pkg set type=module
npm install hono @hono/node-server
npm install -D tsximport { 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) })API_CINEMA_KEY=ac_live_… npx tsx server.tsEssayez 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
npx create-expo-app@latest cinemas
cd cinemas
npm run reset-project # répondre n : supprime les écrans d’exemple
npx expo install expo-locationDans app.json, ajoutez à la liste plugins la phrase qu’iOS affiche en demandant la position :
["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.
EXPO_PUBLIC_RELAY_URL=http://192.168.1.20:8787Le client du relais : 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.
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.
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.
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
npx expo startScannez 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_KEYdans ses secrets, en HTTPS : les builds iOS et Android de production refusent le HTTP simple. Mettez ensuite son adresse dansEXPO_PUBLIC_RELAY_URLet lancez le build avecnpx 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-forvient 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/showtimesavecnear,radius_kmetdateliste 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=VOouVOST: 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.