Séances de cinéma avec Next.js : pages cinéma et appels API en cache

Construire les cinémas d’une ville et le programme de chaque cinéma avec les Server Components de Next.js 16, et garder chaque réponse de l’API en cache une heure avec use cache.

5 min de lecture

Sur cette page
  1. Ce qu’il vous faut
  2. 1.Créer l’application
  3. 2.Le client de l’API
  4. 3.Les cinémas d’une ville
  5. 4.Le programme d’un cinéma
  6. 5.Lancer l’application
  7. Combien de requêtes ?
  8. Pour aller plus loin

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

Terminal
npx create-next-app@latest seances --ts --app --no-tailwind --yes
cd seances

Les 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.

Terminal
echo 'API_CINEMA_KEY=ac_live_…' > .env.local

Le 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.

lib/api-cinema.ts
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}/showtimes renvoie le programme du jour quand date est absent. include=movie intègre le film (titre, affiche, réalisateur) dans chaque séance : une seule requête suffit pour toute la page.
  • startsAt est l’heure affichée au cinéma (2026-11-02T20:30:00) : slice(11, 16) donne 20:30 quel 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.

app/page.tsx
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.

app/cinemas/[slug]/page.tsx
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

Terminal
npm run dev

Ouvrez 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. city exclut la banlieue. Pour « les cinémas autour de moi », appelez /cinemas avec near=lat,lng&radius_km=10 à la place.
  • Versions originales seulement. Ajoutez version: 'VOST' (ou VO) 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.ts qui liste les slugs de getCinemas() 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.