On this page
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
npx create-next-app@latest showtimes --ts --app --no-tailwind --yes
cd showtimesNew 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.
echo 'API_CINEMA_KEY=ac_live_…' > .env.localThe 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.
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}/showtimesreturns today's programme whendateis left out.include=movieembeds the film (title, poster, director) in each showtime, so one request draws the whole page.startsAtis the cinema's wall-clock time (2026-11-02T20:30:00):slice(11, 16)gives20:30whatever 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.
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.
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
npm run devOpen 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.
cityleaves out the suburbs. For "cinemas around me", call/cinemaswithnear=lat,lng&radius_km=10instead. - Original versions only. Add
version: 'VOST'(orVO) 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.tsthat lists the slugs ofgetCinemas()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.