Sur cette page
Un site de cinéma, un média local, un guide de ville : tous ont besoin des deux mêmes pages, les cinémas d’une commune et le programme de chaque cinéma. Ce tutoriel construit les deux avec Next.js 16 (App Router, Server Components) et garde chaque réponse de l’API en cache une heure : une page très visitée coûte une poignée de requêtes par jour, pas une par visiteur.
Ce qu’il vous faut
- Node.js 20 ou plus récent.
- 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 ; un site en production passe à l’offre Starter (12 € par mois, 10 000 requêtes par semaine).
Créer l’application
npx create-next-app@latest seances --ts --app --no-tailwind --yes
cd seancesLes nouvelles applications Next.js 16 ont les Cache Components activés (cacheComponents: true dans next.config.ts) : c’est ce qui permet de mettre les réponses de l’API en cache avec 'use cache'.
La clé va dans .env.local, que Next.js garde côté serveur. Pas de préfixe NEXT_PUBLIC_ : la clé ne doit jamais arriver dans le navigateur.
echo 'API_CINEMA_KEY=ac_live_…' > .env.localLe client de l’API : lib/api-cinema.ts
Chaque appel passe par une seule fonction mise en cache. Ses arguments (chemin et paramètres) forment la clé du cache : chaque page partage une réponse par heure entre tous ses visiteurs.
import { cacheLife } from 'next/cache'
const API = 'https://api.api-cinema.com/v1'
export interface Cinema {
id: string
slug: string
name: string
address: string | null
city: string
postalCode: string | null
timezone: string
website: string | null
}
export interface Movie {
id: string
title: string
durationMin: number | null
director: string | null
posterUrl: string | null
}
export interface Showtime {
id: string
startsAt: string // heure locale du cinéma : "2026-11-02T20:30:00"
version: 'VF' | 'VO' | 'VOST' | 'VFST' | 'UNKNOWN'
format: string | null
bookingUrl: string | null
movie: Movie
}
/** Un GET sur l’API, mis en cache une heure : tous les visiteurs d’une même page partagent la même réponse. */
async function api<T>(path: string, params: Record<string, string> = {}): Promise<T | null> {
'use cache'
cacheLife('hours')
const query = new URLSearchParams(params)
const res = await fetch(`${API}${path}?${query}`, {
headers: { Authorization: `Bearer ${process.env.API_CINEMA_KEY}` },
})
if (res.status === 404)
return null
if (!res.ok)
throw new Error(`API Cinéma a répondu ${res.status}`)
return res.json()
}
export async function getCinemas(city: string): Promise<Cinema[]> {
const res = await api<{ data: Cinema[] }>('/cinemas', { city, limit: '100' })
return res?.data ?? []
}
export async function getCinema(slug: string): Promise<Cinema | null> {
return api<Cinema>(`/cinemas/${encodeURIComponent(slug)}`)
}
/** Une journée de séances d’un cinéma (aujourd’hui si `date` est absent), avec le film de chacune. */
export async function getShowtimes(slug: string, date?: string): Promise<Showtime[]> {
const params: Record<string, string> = { include: 'movie', limit: '1000' }
if (date)
params.date = date
const res = await api<{ data: Showtime[] }>(`/cinemas/${encodeURIComponent(slug)}/showtimes`, params)
return res?.data ?? []
}
/** Séances regroupées par film, le plus programmé d’abord. */
export function byMovie(showtimes: Showtime[]) {
const movies = new Map<string, { movie: Movie, showtimes: Showtime[] }>()
for (const s of showtimes) {
const entry = movies.get(s.movie.id) ?? { movie: s.movie, showtimes: [] }
entry.showtimes.push(s)
movies.set(s.movie.id, entry)
}
return [...movies.values()].sort((a, b) => b.showtimes.length - a.showtimes.length)
}Quelques détails :
cacheLife('hours')garde une réponse jusqu’à une heure. Les séances changent au plus quelques fois par jour : c’est assez frais, et le nombre de requêtes ne dépend plus du nombre de visiteurs.GET /v1/cinemas/{id}/showtimesrenvoie le programme du jour quanddateest absent.include=movieintègre le film (titre, affiche, réalisateur) dans chaque séance : une seule requête suffit pour toute la page.startsAtest l’heure affichée au cinéma (2026-11-02T20:30:00) :slice(11, 16)donne20:30quel que soit le fuseau du serveur.
Les cinémas d’une ville : app/page.tsx
searchParams n’est connu qu’au moment de la requête : la liste est donc dans une frontière <Suspense>. Le titre et le formulaire partent tout de suite, la liste arrive en streaming.
import Link from 'next/link'
import { Suspense } from 'react'
import { getCinemas } from '@/lib/api-cinema'
export default function Home({ searchParams }: PageProps<'/'>) {
return (
<main>
<h1>Cinémas</h1>
<form>
<input name="city" placeholder="Ville, ex. Lyon ou Chicago" />
<button type="submit">Rechercher</button>
</form>
<Suspense fallback={<p>Chargement…</p>}>
<CinemaList searchParams={searchParams} />
</Suspense>
</main>
)
}
async function CinemaList({ searchParams }: Pick<PageProps<'/'>, 'searchParams'>) {
const { city } = await searchParams
const name = typeof city === 'string' && city.trim() ? city.trim() : 'Lyon'
const cinemas = await getCinemas(name)
if (!cinemas.length)
return <p>Aucun cinéma trouvé à {name}.</p>
return (
<ul>
{cinemas.map(c => (
<li key={c.id}>
<Link href={`/cinemas/${c.slug}`}>{c.name}</Link> · {c.address}
</li>
))}
</ul>
)
}city compare exactement le nom de la commune et fonctionne pareil pour la France et les États-Unis : /?city=Lyon, /?city=Chicago.
Le programme d’un cinéma : app/cinemas/[slug]/page.tsx
Chaque cinéma a un slug lisible (astoria-lyon), qui donne une URL propre. Une rangée de liens choisit le jour avec ?date=YYYY-MM-DD.
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
import { byMovie, getCinema, getShowtimes } from '@/lib/api-cinema'
export async function generateMetadata({ params }: PageProps<'/cinemas/[slug]'>): Promise<Metadata> {
const cinema = await getCinema((await params).slug)
return { title: cinema ? `Séances · ${cinema.name}, ${cinema.city}` : 'Cinéma introuvable' }
}
export default function CinemaPage(props: PageProps<'/cinemas/[slug]'>) {
return (
<main>
<Suspense fallback={<p>Chargement des séances…</p>}>
<Programme {...props} />
</Suspense>
</main>
)
}
async function Programme({ params, searchParams }: PageProps<'/cinemas/[slug]'>) {
const { slug } = await params
const { date } = await searchParams
const day = typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : undefined
const [cinema, showtimes] = await Promise.all([getCinema(slug), getShowtimes(slug, day)])
if (!cinema)
notFound()
return (
<>
<h1>{cinema.name}</h1>
<p>{cinema.address}, {cinema.postalCode} {cinema.city}</p>
<nav>
{nextDays(cinema.timezone, 7).map(d => (
<Link key={d.date} href={`/cinemas/${slug}?date=${d.date}`}>{d.label}</Link>
))}
</nav>
{showtimes.length === 0 && <p>Aucune séance ce jour-là.</p>}
{byMovie(showtimes).map(({ movie, showtimes }) => (
<section key={movie.id}>
<h2>{movie.title}</h2>
{movie.posterUrl && <img src={movie.posterUrl} alt="" width={120} />}
<p>{[movie.director, movie.durationMin && `${movie.durationMin} min`].filter(Boolean).join(' · ')}</p>
<ul>
{showtimes.map(s => (
<li key={s.id}>
{/* startsAt est déjà l’heure locale du cinéma : aucune conversion de fuseau */}
<a href={s.bookingUrl ?? undefined}>{s.startsAt.slice(11, 16)}</a> {s.version}
{s.format && s.format !== '2D' && ` · ${s.format}`}
</li>
))}
</ul>
</section>
))}
</>
)
}
/** Les `count` prochains jours en YYYY-MM-DD dans le fuseau du cinéma, avec un libellé court. */
function nextDays(timeZone: string, count: number) {
const iso = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' })
const label = new Intl.DateTimeFormat('fr', { timeZone, weekday: 'short', day: 'numeric' })
return Array.from({ length: count }, (_, i) => {
const d = new Date(Date.now() + i * 86_400_000)
return { date: iso.format(d), label: i === 0 ? 'Aujourd’hui' : label.format(d) }
})
}generateMetadata et la page appellent getCinema avec le même slug : grâce au cache, cela fait une requête, pas deux. Les jours sont calculés dans le fuseau du cinéma (cinema.timezone) : « Aujourd’hui » est juste pour un cinéma de Paris comme de Los Angeles.
Lancer l’application
npm run devOuvrez http://localhost:3000/?city=Lyon, choisissez un cinéma, puis changez de jour. Rechargez la page : la réponse vient du cache et aucune requête n’est comptée.
Combien de requêtes ?
Chaque URL distincte coûte au plus une requête par appel à l’API et par heure : une page de cinéma (cinéma + séances) fait 2 requêtes par heure, 48 par jour si elle est visitée toute la journée, quel que soit le nombre de visiteurs. Un site qui suit 5 cinémas sur 7 jours reste sous les 2 000 requêtes par semaine, largement dans l’offre Starter.
Chez un hébergeur serverless, le 'use cache' par défaut vit dans la mémoire de chaque instance et n’est pas toujours partagé entre les requêtes. Si le tableau de bord montre plus de requêtes que prévu, passez à 'use cache: remote', que Vercel et la plupart des hébergeurs adossent à un stockage partagé.
Pour aller plus loin
- Toute une agglomération.
cityexclut la banlieue. Pour « les cinémas autour de moi », appelez/cinemasavecnear=lat,lng&radius_km=10à la place. - Versions originales seulement. Ajoutez
version: 'VOST'(ouVO) aux paramètres des séances pour une page de ciné-club. - Un film partout.
GET /v1/showtimes?tmdb_id=…&city=…liste toutes les séances d’un film dans une ville, quel que soit le titre publié par chaque cinéma. - Référencement. Chaque page de cinéma a son propre titre ; ajoutez un
sitemap.tsqui liste les slugs degetCinemas()pour que les moteurs trouvent chaque programme.
Chaque paramètre et chaque champ sont dans la documentation ; le guide pour la France et le guide pour les États-Unis montrent les autres recherches.