diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx
index dd8716d..c91f64d 100644
--- a/app/privacy/page.test.tsx
+++ b/app/privacy/page.test.tsx
@@ -97,6 +97,18 @@ describe("/privacy", () => {
);
});
+ /** covers: prompts/tmdb-image-loader.md, AC-6 */
+ it("says images load from TMDB, not from the host", () => {
+ const { container } = render();
+ const text = container.textContent ?? "";
+
+ expect(text).toContain("Your browser loads the images from");
+ expect(text).not.toContain("our host delivers the images");
+ expect(
+ PROCESSORS.find((processor) => processor.name === "Vercel")?.purpose,
+ ).not.toMatch(/image/i);
+ });
+
it("opens external links in a new tab, safely (AC-7)", () => {
const { container } = render();
@@ -135,6 +147,18 @@ describe("/privacy", () => {
expect(text).toContain("EU Standard Contractual Clauses");
});
+ it("states each processor's transfer from the one module", () => {
+ const { container } = render();
+ const text = container.textContent ?? "";
+
+ for (const processor of PROCESSORS) {
+ if (processor.transfer) expect(text).toContain(processor.transfer);
+ }
+ expect(
+ PROCESSORS.find((processor) => processor.name === "TMDB")?.transfer,
+ ).toMatch(/outside the EEA/);
+ });
+
it("promises no account email production can't send (spec 0018, AC-23)", () => {
const { container } = render();
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx
index 445c21a..90084d6 100644
--- a/app/privacy/page.tsx
+++ b/app/privacy/page.tsx
@@ -18,6 +18,7 @@ import {
RIGHTS_RESPONSE,
SUPABASE_REGION,
SUPERVISORY_AUTHORITY,
+ TMDB_HOME_URL,
} from "@/lib/legal/operator";
import { catalogMetadata } from "@/lib/seo/metadata";
@@ -134,9 +135,11 @@ export default function PrivacyPage() {
Catalog details come from{" "}
- TMDB,
- which receives no personal data from you: our server makes the catalog
- requests, and our host delivers the images.
+ TMDB. Our server
+ makes the catalog requests, so TMDB never receives your account or
+ tracking data. Your browser loads the images from TMDB’s image
+ service, which sees your IP address and browser details, and
+ TMDB’s own privacy policy applies to them.
@@ -146,11 +149,13 @@ export default function PrivacyPage() {
{SUPABASE_REGION.name}, and the app’s server functions in the
matching Vercel region.
-
- Supabase and Vercel are United States companies. Where their staff
- access your data for support and operations, that access is covered by
- the EU Standard Contractual Clauses.
-
+ {PROCESSORS.flatMap((processor) =>
+ processor.transfer ? (
+ {processor.transfer}
+ ) : (
+ []
+ ),
+ )}
diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx
index e68ba8b..02d7411 100644
--- a/app/showcase/page.tsx
+++ b/app/showcase/page.tsx
@@ -50,6 +50,7 @@ import {
import { Input } from "@/components/ui/input";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { catalogList, listPath } from "@/lib/catalog/lists";
+import { TMDB_HOME_URL } from "@/lib/legal/operator";
import { CardRoundButtonDemo } from "./card-round-button-demo";
@@ -568,9 +569,7 @@ export default function ShowcasePage() {
Body text in the secondary token, with an{" "}
-
- external link
- {" "}
+ external link{" "}
and the contact address, .
diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx
index ec9741f..e09c680 100644
--- a/components/layout/site-footer.tsx
+++ b/components/layout/site-footer.tsx
@@ -2,7 +2,11 @@ import { cn } from "cn";
import Image from "next/image";
import Link from "next/link";
import type * as React from "react";
-import { OPERATOR_GITHUB_URL, OPERATOR_HANDLE } from "@/lib/legal/operator";
+import {
+ OPERATOR_GITHUB_URL,
+ OPERATOR_HANDLE,
+ TMDB_HOME_URL,
+} from "@/lib/legal/operator";
import { TMDB_ATTRIBUTION } from "@/lib/tmdb";
import { IconKey } from "./icon-key";
@@ -32,7 +36,7 @@ export function SiteFooter() {
{
]);
});
+ it("writes TMDB's site URL only in lib/legal/operator.ts", () => {
+ expect(filesContaining(TMDB_HOME_URL)).toEqual(["lib/legal/operator.ts"]);
+ });
+
+ it("names no processor's country in /privacy itself", () => {
+ expect(readFileSync("app/privacy/page.tsx", "utf8")).not.toContain(
+ "United States",
+ );
+ });
+
it("writes the JustWatch credit only in lib/legal/operator.ts (spec 0022, AC-12)", () => {
expect(filesContaining(JUSTWATCH_ATTRIBUTION)).toEqual([
"lib/legal/operator.ts",
diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts
index 42a45a2..5aedef4 100644
--- a/lib/legal/operator.ts
+++ b/lib/legal/operator.ts
@@ -42,7 +42,7 @@ export const MINIMUM_AGE = 16;
* whenever the text changes materially. Pages format it in UTC and never read
* the clock (spec 0017, AC-7).
*/
-export const LEGAL_LAST_UPDATED = "2026-10-12";
+export const LEGAL_LAST_UPDATED = "2026-10-16";
/**
* Where the production Supabase project stores your data, read from
@@ -63,6 +63,15 @@ export const SUPABASE_REGION = {
*/
export const JUSTWATCH_ATTRIBUTION = "Availability data from JustWatch";
+/**
+ * TMDB's own site, where the footer logo, the privacy policy and TMDB's
+ * processor entry link. One home, like `JUSTWATCH_ATTRIBUTION`, so the link
+ * cannot drift between the places that name TMDB. It lives here rather than in
+ * `lib/tmdb` because that entry point is server only and the footer test and
+ * the legal pages render in jsdom.
+ */
+export const TMDB_HOME_URL = "https://www.themoviedb.org";
+
/** Days within which an account deletion request is completed. */
export const DELETION_DAYS = 30;
@@ -72,27 +81,48 @@ export const RIGHTS_RESPONSE = "one month";
/** Days within which a complaint about the service is answered. */
export const COMPLAINT_RESPONSE_DAYS = 14;
-/** A service provider that processes personal data on the operator's behalf. */
-export type Processor = { name: string; purpose: string; url: string };
+/**
+ * A service provider that processes personal data on the operator's behalf.
+ * `transfer`, when set, is what "Transfers outside the EEA" says about it, so
+ * the company's country lives beside its other facts and not in page text.
+ */
+export type Processor = {
+ name: string;
+ purpose: string;
+ url: string;
+ transfer?: string;
+};
/**
* Only the processors live today. Production sends no email (spec 0018), so
* neither Google nor an email provider is listed; both join with the deferred
* email delivery and Google sign in work, in the PR that turns them on.
* YouTube joined with the title page trailers (spec 0022, AC-19): its player
- * loads only after you press Trailer.
+ * loads only after you press Trailer. TMDB joined when images moved off
+ * Vercel's optimizer onto TMDB's own CDN (prompts/tmdb-image-loader.md).
*/
export const PROCESSORS: readonly Processor[] = [
{
name: "Supabase",
purpose: "Stores your account and tracking data and handles sign in.",
url: "https://supabase.com",
+ transfer:
+ "Supabase is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.",
},
{
name: "Vercel",
- purpose:
- "Hosts the website, delivers catalog images, and keeps request logs.",
+ purpose: "Hosts the website and keeps request logs.",
url: "https://vercel.com",
+ transfer:
+ "Vercel is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.",
+ },
+ {
+ name: "TMDB",
+ purpose:
+ "Delivers posters, photos and other catalog images straight to your browser, which shares your IP address with it.",
+ url: TMDB_HOME_URL,
+ transfer:
+ "TMDB is a United States company. The images your browser loads from it come from its content delivery network, which may serve them from outside the EEA.",
},
{
name: "YouTube",
diff --git a/lib/tmdb/AGENTS.md b/lib/tmdb/AGENTS.md
index fefe133..c1d4266 100644
--- a/lib/tmdb/AGENTS.md
+++ b/lib/tmdb/AGENTS.md
@@ -1,6 +1,7 @@
# AGENTS.md · lib/tmdb
-The server only module every TMDB request goes through. Decided in
+The server only module every TMDB request goes through, plus the one browser
+file, `image-loader.ts`, that `next.config.ts` hands to `next/image`. Decided in
[spec 0002](../../docs/specs/0002-tmdb-integration-module/index.md); scope
feature 4. Runtime steps live in that spec's `verify.md`.
@@ -8,7 +9,9 @@ feature 4. Runtime steps live in that spec's `verify.md`.
- `index.ts` is the only entry point a page may import. It exports the cached
reads, `imageUrl`, `isTmdbNotFound`, `TmdbError`, `TMDB_ATTRIBUTION` and the
- normalized types. Everything else in this folder is internal.
+ normalized types. Everything else in this folder is internal, except
+ `image-loader.ts` (`'use client'`), which only the `loaderFile` setting
+ imports, and the pure `images.ts` it reads.
- `reads.ts` holds every `use cache` wrapper and is the only file that imports
`next/cache`.
- `client.ts` owns auth, the deadline, the retry budget, the status mapping and
@@ -69,6 +72,14 @@ feature 4. Runtime steps live in that spec's `verify.md`.
(`resolveOverview` in `normalize.ts`, spec 0006, AC-5).
- **Errors carry a `kind`**, never a bare status. Pages branch through
`isTmdbNotFound`, not through a hand rolled catch.
+- **Images skip Vercel's optimizer.** `image-loader.ts` (`'use client'`, the
+ `loaderFile` in `next.config.ts`) maps a `next/image` width onto TMDB's own
+ sizes, never above the size `images.ts` pinned and never `original`, so the
+ browser loads straight from TMDB's CDN (prompts/tmdb-image-loader.md). A new
+ image field keeps a size from `TMDB_IMAGE_WIDTHS` in `images.ts`, the one
+ list the loader and `TmdbImageSize` share. `h632`, another host or a size
+ off that list passes through unresized, and Next's dev warning ("does not
+ implement width") names it.
- Adding a field the app needs is a three place change: `schemas.ts`,
`types.ts`, `normalize.ts`.
diff --git a/lib/tmdb/image-loader.test.ts b/lib/tmdb/image-loader.test.ts
new file mode 100644
index 0000000..8165e3e
--- /dev/null
+++ b/lib/tmdb/image-loader.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+
+import { TMDB_IMAGE_BASE } from "./constants";
+import tmdbImageLoaderDefault, { tmdbImageLoader } from "./image-loader";
+
+/**
+ * covers: prompts/tmdb-image-loader.md, AC-1 to AC-3
+ *
+ * The loader is what keeps Vercel Image Optimization at zero transformations,
+ * so a wrong rung is either a broken TMDB URL (a 400 for `w640`) or a heavier
+ * image than the field size allows. Both are invisible until someone looks.
+ */
+const at = (size: string) => `${TMDB_IMAGE_BASE}/${size}/abc123.jpg`;
+/** The source's own size comes back marked, so Next sees the width honoured. */
+const same = (size: string) => `${at(size)}#same-size`;
+
+describe("tmdbImageLoader", () => {
+ it("is the module's default export, which loaderFile needs", () => {
+ expect(tmdbImageLoaderDefault).toBe(tmdbImageLoader);
+ });
+
+ it.each([
+ [32, "w92"],
+ [92, "w92"],
+ [93, "w154"],
+ [200, "w300"],
+ [342, "w342"],
+ ])("serves a w500 poster at width %i from %s (AC-1)", (width, size) => {
+ expect(tmdbImageLoader({ src: at("w500"), width, quality: 75 })).toBe(
+ at(size),
+ );
+ });
+
+ it.each([384, 3840])(
+ "serves a w500 poster at width %i from its own size, marked so Next does not warn",
+ (width) => {
+ expect(tmdbImageLoader({ src: at("w500"), width })).toBe(same("w500"));
+ },
+ );
+
+ it.each([[640, "w780"]])(
+ "serves a w1280 backdrop at width %i from %s (AC-2)",
+ (width, size) => {
+ expect(tmdbImageLoader({ src: at("w1280"), width })).toBe(at(size));
+ },
+ );
+
+ it("serves a w1280 backdrop from its own size above w780 (AC-2)", () => {
+ expect(tmdbImageLoader({ src: at("w1280"), width: 828 })).toBe(
+ same("w1280"),
+ );
+ });
+
+ it("never asks for more than the pinned profile size", () => {
+ expect(tmdbImageLoader({ src: at("w185"), width: 384 })).toBe(same("w185"));
+ });
+
+ it("caps an original source at w1280 and still sizes it down (AC-3)", () => {
+ expect(tmdbImageLoader({ src: at("original"), width: 3840 })).toBe(
+ at("w1280"),
+ );
+ expect(tmdbImageLoader({ src: at("original"), width: 128 })).toBe(
+ at("w154"),
+ );
+ });
+
+ it.each([
+ at("h632"),
+ at("w45"),
+ `${TMDB_IMAGE_BASE}/abc123.jpg`,
+ "https://example.com/t/p/w500/abc123.jpg",
+ "/tmdb-logo.svg",
+ ])(
+ "returns %s unchanged, leaving Next's width warning to flag it (AC-3)",
+ (src) => {
+ expect(tmdbImageLoader({ src, width: 200 })).toBe(src);
+ },
+ );
+});
diff --git a/lib/tmdb/image-loader.ts b/lib/tmdb/image-loader.ts
new file mode 100644
index 0000000..94bf55d
--- /dev/null
+++ b/lib/tmdb/image-loader.ts
@@ -0,0 +1,55 @@
+"use client";
+
+import { TMDB_IMAGE_BASE } from "./constants";
+import { splitImageUrl, TMDB_IMAGE_WIDTHS } from "./images";
+
+/** What `original` is capped to, so a full size source never ships as is. */
+const ORIGINAL_CEILING = 1280;
+
+/**
+ * Appended when the fitting size is the source's own. Next warns in
+ * development that a loader "does not implement width" whenever it returns
+ * `src` unchanged, which every image at its pinned size would. A fragment never
+ * reaches TMDB, so the request and its CDN cache entry are the same file.
+ */
+const SAME_SIZE_FRAGMENT = "#same-size";
+
+type LoaderParams = { src: string; width: number; quality?: number };
+
+/**
+ * Maps a `next/image` width request onto TMDB's own pre-sized images, so the
+ * browser fetches straight from TMDB's CDN and Vercel Image Optimization runs
+ * no transformations (prompts/tmdb-image-loader.md). TMDB already negotiates
+ * WebP from the `Accept` header, so nothing is lost by skipping the optimizer.
+ *
+ * The size `lib/tmdb/images.ts` pinned is the ceiling: a `w500` poster is never
+ * upgraded on a dense screen, so no page gets heavier than its field size allows.
+ * `quality` is ignored because TMDB has no such parameter. Any URL that is not
+ * a TMDB size path on the ladder comes back unchanged, so the loader never
+ * builds a URL for a host it was not handed. Next's development warning then
+ * names that image, which is the signal to pin it to a ladder size.
+ */
+export function tmdbImageLoader({ src, width }: LoaderParams): string {
+ const parts = splitImageUrl(src);
+ if (!parts) return src;
+ const ceiling = sizeCeiling(parts.size);
+ if (ceiling === null) return src;
+
+ // `original` is left off the ladder because it can be several megabytes.
+ const fit = TMDB_IMAGE_WIDTHS.find((rung) => rung >= width) ?? ceiling;
+ const url = `${TMDB_IMAGE_BASE}/w${Math.min(fit, ceiling)}${parts.path}`;
+ return url === src ? `${url}${SAME_SIZE_FRAGMENT}` : url;
+}
+
+/** The pixel width a source size allows, or null when it is not on the ladder. */
+function sizeCeiling(size: string): number | null {
+ if (size === "original") return ORIGINAL_CEILING;
+ const match = /^w(\d+)$/.exec(size);
+ if (!match) return null;
+ const value = Number(match[1]);
+ return (TMDB_IMAGE_WIDTHS as readonly number[]).includes(value)
+ ? value
+ : null;
+}
+
+export default tmdbImageLoader;
diff --git a/lib/tmdb/images.ts b/lib/tmdb/images.ts
index b77e945..8d44c91 100644
--- a/lib/tmdb/images.ts
+++ b/lib/tmdb/images.ts
@@ -1,22 +1,47 @@
import { TMDB_IMAGE_BASE } from "./constants";
/**
- * The TMDB image widths this app is allowed to request.
+ * The TMDB widths this app may request, smallest first. Each answers 200 for
+ * posters, backdrops, profiles and stills alike (checked live on 2026-10-16).
+ *
+ * One list for both the pinned sizes below and the `next/image` loader's
+ * ladder (`image-loader.ts`), so a width the loader can pick is always one the
+ * type allows, and adding a size is one edit.
+ */
+export const TMDB_IMAGE_WIDTHS = [
+ 92, 154, 185, 300, 342, 500, 780, 1280,
+] as const;
+
+/**
+ * The TMDB image sizes this app is allowed to request.
*
* A closed union rather than a free string: a typo in a size produces a URL
* TMDB answers with a 404 image, which is invisible until someone looks at the
* page. `original` is included for the rare full size case.
*/
export type TmdbImageSize =
- | "w92"
- | "w154"
- | "w185"
- | "w300"
- | "w500"
- | "w780"
- | "w1280"
+ | `w${(typeof TMDB_IMAGE_WIDTHS)[number]}`
| "original";
+/** A TMDB image URL split at its size segment. */
+export type TmdbImageParts = { size: string; path: string };
+
+/**
+ * Splits an absolute TMDB image URL into its size segment and file path, or
+ * returns null for a URL this module did not build. The one parser both
+ * `resizeImageUrl` and the `next/image` loader use, so they agree on what a
+ * TMDB size path is. The size comes back as a plain string because a URL from
+ * TMDB itself can carry sizes the app never requests, such as `h632`.
+ */
+export function splitImageUrl(url: string): TmdbImageParts | null {
+ const prefix = `${TMDB_IMAGE_BASE}/`;
+ if (!url.startsWith(prefix)) return null;
+ const rest = url.slice(prefix.length);
+ const slash = rest.indexOf("/");
+ if (slash === -1) return null;
+ return { size: rest.slice(0, slash), path: rest.slice(slash) };
+}
+
/**
* The size each normalized field is built with (spec 0002, AC-27).
*
@@ -72,10 +97,6 @@ export function resizeImageUrl(
size: TmdbImageSize,
): string | null {
if (!url) return null;
- const prefix = `${TMDB_IMAGE_BASE}/`;
- if (!url.startsWith(prefix)) return url;
- const rest = url.slice(prefix.length);
- const slash = rest.indexOf("/");
- if (slash === -1) return url;
- return `${prefix}${size}${rest.slice(slash)}`;
+ const parts = splitImageUrl(url);
+ return parts ? `${TMDB_IMAGE_BASE}/${size}${parts.path}` : url;
}
diff --git a/next.config.test.ts b/next.config.test.ts
index a8c0e0c..282e540 100644
--- a/next.config.test.ts
+++ b/next.config.test.ts
@@ -84,6 +84,12 @@ describe("next.config", () => {
]);
});
+ /** covers: prompts/tmdb-image-loader.md, AC-4 */
+ it("loads images through the TMDB loader, never Vercel's optimizer", () => {
+ expect(nextConfig.images?.loader).toBe("custom");
+ expect(nextConfig.images?.loaderFile).toBe("./lib/tmdb/image-loader.ts");
+ });
+
it("keeps Cache Components on, which every route is written against", () => {
expect(nextConfig.cacheComponents).toBe(true);
});
diff --git a/next.config.ts b/next.config.ts
index de9abcd..c65d5f7 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -60,7 +60,15 @@ const nextConfig: NextConfig = {
: []),
];
},
+ /**
+ * A custom loader points every `next/image` at TMDB's own pre-sized files, so
+ * the browser loads from TMDB's CDN and Vercel's optimizer, whose Hobby quota
+ * a catalog this size outgrows, never runs (prompts/tmdb-image-loader.md).
+ * `remotePatterns` stays as the record of the one image host.
+ */
images: {
+ loader: "custom",
+ loaderFile: "./lib/tmdb/image-loader.ts",
remotePatterns: [
{
protocol: "https",
diff --git a/prompts/tmdb-image-loader.md b/prompts/tmdb-image-loader.md
new file mode 100644
index 0000000..a43e441
--- /dev/null
+++ b/prompts/tmdb-image-loader.md
@@ -0,0 +1,157 @@
+# TMDB image loader
+
+## Goal
+
+Stop Vercel Image Optimization from running on every TMDB image. The Hobby
+quota (5,000 transformations a month) is at 5.1K. A transformation is one unique
+source URL × width × quality × format, so a catalog with thousands of posters,
+backdrops, profiles and stills, several `srcset` widths each, and crawlers that
+walk the sitemap will pass that quota at any traffic level.
+
+TMDB's CDN already serves every image pre-sized (`w92` … `w1280`) and returns
+WebP when the browser's `Accept` header allows it, so Vercel's resize is
+redundant. A custom `next/image` loader sends the browser straight to the
+TMDB size that fits, so Vercel does no transformations at all.
+
+## Inspected
+
+- `next.config.ts`: `images.remotePatterns` for `image.tmdb.org/t/p/**` and
+ nothing else, so the default loader with default `deviceSizes`/`imageSizes`
+ is in use.
+- `lib/tmdb/images.ts`: `TmdbImageSize`, the pinned per field sizes
+ (`POSTER_SIZE` w500, `BACKDROP_SIZE` w1280, `PROFILE_SIZE` w185,
+ `STILL_SIZE` w300, `COMPANY_LOGO_SIZE` w154, `PROVIDER_LOGO_SIZE` w92),
+ `imageUrl`, `resizeImageUrl`.
+- Every `next/image` user: `poster-card`, `list-cards`, `catalog-list-cards`,
+ `carousel-row`, `cast-row`, `title-extras`, `detail-hero`, `season-header`,
+ `season-grid`, `episode-row`, `quick-search-row`, `where-to-watch`,
+ `title-details`, the library cards, `/showcase`. All pass TMDB URLs built by
+ `lib/tmdb`. `site-footer` renders the local TMDB logo SVG `unoptimized`, so
+ the loader is never called for it.
+- `node_modules/next/dist/docs/.../images.md`: `loader: "custom"` +
+ `loaderFile`, a `'use client'` module with a default export
+ `({ src, width, quality }) => string`.
+- `node_modules/next/dist/shared/lib/get-img-props.js`: in development Next
+ warns when the loader's URL equals `src`, i.e. it "does not implement width".
+- Live TMDB check (2026-10-16): `w45, w92, w154, w185, w300, w342, w500, w780,
+ w1280, h632, original` all return 200 for a poster, a backdrop and a profile
+ path; `w640` returns 400. Responses carry `cache-control: public,
+ max-age=31919000` and come back as `image/webp` for `Accept: image/webp`.
+- `lib/legal/operator.ts` and `app/privacy/page.tsx`: the policy says TMDB
+ "receives no personal data from you … our host delivers the images", and
+ `PROCESSORS` gives Vercel the job "delivers catalog images".
+
+## Skills used
+
+None of the installed skills cover image loaders. The Next.js docs shipped in
+`node_modules` are the reference.
+
+## Decisions
+
+1. **Images load directly from `image.tmdb.org`** (your choice, 2026-10-16),
+ not through a same-origin Vercel rewrite, which would only swap image
+ transformations for edge requests and origin transfer.
+2. **Width ladder:** `w92, w154, w185, w300, w342, w500, w780, w1280`, kept as
+ `TMDB_IMAGE_WIDTHS` in `images.ts`, the one list `TmdbImageSize` is built
+ from too (amended after review). The loader picks the smallest rung ≥ the requested width. `original` is never
+ chosen, because it can be several MB.
+3. **The size `lib/tmdb` pinned stays the ceiling.** A `w500` poster is never
+ upgraded to `w780` on a 3× screen, so no image becomes heavier than it is
+ today and `images.ts`' rule that a page can't quietly request larger images
+ still holds. Wide `srcset` entries collapse to the same URL, and the browser
+ dedupes them.
+4. **Anything else passes through unchanged:** a URL that isn't
+ `https://image.tmdb.org/t/p//`, or that has an unknown size,
+ comes back untouched. The loader never builds a URL for another host.
+ Next's dev warning ("does not implement width") then names that image, the
+ signal to pin it to a ladder size; no caller does so today.
+ `remotePatterns` stays in the config as documentation of the one allowed
+ host.
+5. **`quality` is ignored**, because TMDB has no quality parameter.
+5a. **A URL already at its own size gets a `#same-size` fragment** (found while
+ verifying): Next's dev check warns "does not implement width" whenever the
+ loader returns `src` unchanged, which every image at its pinned size does.
+ A fragment never reaches TMDB, so the request and its cache entry are
+ unchanged.
+6. **Privacy text follows the code** (AGENTS.md: legal pages describe only what
+ the code does):
+ - Vercel's `PROCESSORS` purpose becomes "Hosts the website and keeps request
+ logs."
+ - TMDB joins `PROCESSORS` ("Delivers posters, photos and other catalog
+ images straight to your browser, which shares your IP address with it.",
+ `https://www.themoviedb.org`), following the YouTube precedent.
+ - The paragraph under "Who processes your data" becomes: our server makes
+ the catalog requests, so TMDB receives no account or tracking data; your
+ browser loads the images from TMDB's image service, which sees your IP
+ address and browser details, and TMDB's privacy policy applies.
+ - "Transfers outside the EEA" adds that TMDB is a United States company.
+ - `LEGAL_LAST_UPDATED` moves to `2026-10-16`.
+ - The wording is the operator's legal text. Review it in the PR.
+
+## Expected files
+
+- `lib/tmdb/image-loader.ts` (new): `'use client'`, pure, the default export
+ loader plus an exported `tmdbImageLoader` for tests. Imports only
+ `TMDB_IMAGE_BASE` and the size type, nothing `server-only`.
+- `lib/tmdb/image-loader.test.ts` (new).
+- `next.config.ts`: `images.loader: "custom"`, `loaderFile:
+ "./lib/tmdb/image-loader.ts"`, and its JSDoc saying why.
+- `next.config.test.ts`: pin `loader: "custom"` and the loader file.
+- `lib/legal/operator.ts`, `app/privacy/page.tsx`, `app/privacy/page.test.tsx`
+ where needed.
+- `lib/tmdb/AGENTS.md`: one line on the loader and the ceiling rule.
+
+## Requirements
+
+- R1. No `next/image` in the app requests `/_next/image`.
+- R2. Every TMDB `srcset` candidate is a valid TMDB size path no larger than the
+ size `lib/tmdb` chose.
+- R3. Non TMDB or unrecognised URLs pass through unchanged.
+- R4. No visual change: same layout, `sizes`, placeholders and missing image
+ states.
+- R5. The privacy page and `PROCESSORS` describe the new image path.
+
+## Security and privacy
+
+- The loader runs in the browser, but it only rewrites the size segment of
+ public CDN URLs. No token or env value is involved, and
+ `security-boundary.test.ts` still holds.
+- Visitors' IP addresses now reach TMDB's image CDN. The privacy text changes
+ in the same PR, as AGENTS.md requires.
+- There is no open redirect or host widening: the loader never builds a URL for
+ a host it was not given.
+
+## Acceptance criteria
+
+- AC-1. `tmdbImageLoader({ src: ".../w500/x.jpg", width: 200 })` returns
+ `.../w300/x.jpg`. Width 92 returns `w92`, width 342 returns `w342`, and width
+ 3840 returns `.../w500/x.jpg#same-size` (the ceiling, marked per 5a).
+- AC-2. A `w1280` backdrop at width 828 returns `.../w1280/x.jpg#same-size`.
+ At width 640 it returns `w780`.
+- AC-3. `original`, `h632` or unknown sources: the ceiling is treated as
+ `w1280` for `original`, and anything not on the ladder or not under the TMDB
+ base is returned unchanged.
+- AC-4. `next.config` declares `loader: "custom"` and `loaderFile`.
+- AC-5. A production build and a page load show `
` pointing at
+ `image.tmdb.org`, with no `/_next/image` requests and no loader width
+ warning in dev.
+- AC-6. `/privacy` lists TMDB, Vercel's purpose no longer mentions images, and
+ `Last updated` reads 16 October 2026.
+
+## Automated checks
+
+`pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`.
+
+## Manual test steps
+
+1. `pnpm dev`, then open `/shows`. In DevTools → Network → Img, every poster
+ request goes to `image.tmdb.org/t/p/w…/…`, and none to `/_next/image`.
+2. Inspect a poster `
`: `srcset` lists TMDB size URLs, none larger than
+ `w500`.
+3. Open a show page: the backdrop loads from `w780`/`w1280`, cast photos from
+ `w92`–`w185`, and the season episode stills from up to `w300`.
+4. Toggle a mobile viewport and reload: smaller rungs are fetched.
+5. Check that a title with no poster still shows the placeholder.
+6. Open `/privacy`: TMDB is listed and the date is updated.
+7. After deploy, Vercel → Usage → Image Optimization: the transformation count
+ stops growing.