Movie showtimes in Next.js: cinema pages with cached API calls

Build the cinemas of a city and the programme of each cinema with Next.js 16 Server Components, and cache every API answer for an hour with use cache. For France and the US.

5 min read

On this page
  1. What you need
  2. 1.Create the app
  3. 2.The API client
  4. 3.The cinemas of a city
  5. 4.The programme of a cinema
  6. 5.Run it
  7. How many requests does it cost?
  8. Going further

A cinema website, a local news site, a city guide: they all need the same two pages, the cinemas of a town and the programme of each cinema. This tutorial builds both with Next.js 16 (App Router, Server Components) and keeps every API answer in cache for an hour, so a busy page costs a handful of requests a day, not one per visitor.

What you need

  • Node.js 20 or later.
  • 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; a site in production moves to the Starter plan (€12 a month, 10,000 requests a week).

Create the app

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

New Next.js 16 apps come with Cache Components on (cacheComponents: true in next.config.ts): that is what lets us cache the API answers with 'use cache'.

The key goes in .env.local, which Next.js keeps on the server. No NEXT_PUBLIC_ prefix: the key must never reach the browser.

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

The API client: lib/api-cinema.ts

Every call goes through one cached function. Its arguments (path and query) are the cache key, so each page shares one answer per hour across all its visitors.

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 // the cinema's local time: "2026-11-02T20:30:00"
  version: 'VF' | 'VO' | 'VOST' | 'VFST' | 'UNKNOWN'
  format: string | null
  bookingUrl: string | null
  movie: Movie
}

/** One GET on the API, cached for an hour: every visitor of the same page shares the same answer. */
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 answered ${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)}`)
}

/** A day of showtimes of one cinema (today when `date` is left out), with the film of each. */
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 ?? []
}

/** Showtimes grouped by film, the most programmed first. */
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)
}

A few details:

  • cacheLife('hours') keeps an answer for up to an hour. Showtimes change a few times a day at most, so this is fresh enough and divides the requests by the number of visitors.
  • GET /v1/cinemas/{id}/showtimes returns today's programme when date is left out. include=movie embeds the film (title, poster, director) in each showtime, so one request draws the whole page.
  • startsAt is the cinema's wall-clock time (2026-11-02T20:30:00): slice(11, 16) gives 20:30 whatever the time zone of the server.

The cinemas of a city: app/page.tsx

searchParams is only known at request time, so the list sits in a <Suspense> boundary: the heading and the form are sent at once, the list streams in.

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>Cinemas</h1>
      <form>
        <input name="city" placeholder="City, e.g. Lyon or Chicago" />
        <button type="submit">Search</button>
      </form>
      <Suspense fallback={<p>Loading…</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>No cinema found in {name}.</p>
  return (
    <ul>
      {cinemas.map(c => (
        <li key={c.id}>
          <Link href={`/cinemas/${c.slug}`}>{c.name}</Link> · {c.address}
        </li>
      ))}
    </ul>
  )
}

city is an exact match on the town name and works the same for France and the United States: /?city=Lyon, /?city=Chicago.

The programme of a cinema: app/cinemas/[slug]/page.tsx

Each cinema has a readable slug (astoria-lyon), which makes a clean URL. A row of links picks the day with ?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 ? `Showtimes · ${cinema.name}, ${cinema.city}` : 'Cinema not found' }
}

export default function CinemaPage(props: PageProps<'/cinemas/[slug]'>) {
  return (
    <main>
      <Suspense fallback={<p>Loading showtimes…</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>No showtime on this day.</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 is already the cinema's local time: no time zone conversion */}
                <a href={s.bookingUrl ?? undefined}>{s.startsAt.slice(11, 16)}</a> {s.version}
                {s.format && s.format !== '2D' && ` · ${s.format}`}
              </li>
            ))}
          </ul>
        </section>
      ))}
    </>
  )
}

/** The next `count` days as YYYY-MM-DD in the cinema's time zone, with a short label. */
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('en', { 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 ? 'Today' : label.format(d) }
  })
}

generateMetadata and the page call getCinema with the same slug: thanks to the cache, that is one request, not two. The days are computed in the cinema's own time zone (cinema.timezone), so "Today" is right for a cinema in Paris as well as in Los Angeles.

Run it

Terminal
npm run dev

Open http://localhost:3000/?city=Lyon, pick a cinema, then switch days. Reload the page: the answer comes from the cache and no request is counted.

How many requests does it cost?

Each distinct URL costs at most one request per API call per hour: a cinema page (cinema + showtimes) is 2 requests an hour, 48 a day if it is visited all day long, whatever the number of visitors. A site that follows 5 cinemas over 7 days stays under 2,000 requests a week, well within the Starter plan.

On a serverless host, the default 'use cache' lives in each instance's memory and may not be shared between requests. If the dashboard shows more requests than expected, switch to 'use cache: remote', which Vercel and most hosts back with a shared store.

Going further

  • A whole metro area. city leaves out the suburbs. For "cinemas around me", call /cinemas with near=lat,lng&radius_km=10 instead.
  • Original versions only. Add version: 'VOST' (or VO) to the showtimes parameters for a film-club page.
  • One film everywhere. GET /v1/showtimes?tmdb_id=…&city=… lists every showtime of a film in a city, whatever title each cinema publishes.
  • SEO. Each cinema page has its own title; add a sitemap.ts that lists the slugs of getCinemas() so search engines find every programme.

Every parameter and field is in the documentation; the guide for France and the guide for the United States show the other searches.