A film catalog rendered entirely from a headless CMS: Next.js server components query Sanity's content lake with GROQ — movies joined to their cast and crew, synopses rendered from Portable Text, and every poster served through a crop-aware image pipeline.
Runs against Sanity's public movies demo dataset (zp7mbokg), so the whole project works with zero credentials, zero backend, and zero environment variables — clone, install, run.
- Headless CMS architecture — content lives in Sanity's content lake; the front end is a standalone Next.js app speaking GROQ over HTTP. No studio, no SDK — the API client is ~30 lines of
fetch. - GROQ joins, both directions — movie pages dereference cast/crew (
person->{...}), and person pages run a reverse join (*[_type == "movie" && references(^._id)]) to build a filmography with the person's roles resolved per film. - A hand-rolled Portable Text renderer — paragraphs, headings, blockquotes, strong/em/underline/code marks, link markDefs, and nested bullet/number lists rendered to React with no dependencies.
- Crop-aware image pipeline — Sanity image refs (
image-<id>-<w>x<h>-<fmt>) are parsed and turned into CDN URLs; editor crops become integerrect=params computed from crop fractions, composed with Next.js<Image>optimization. - Pure, tested core — ref parsing, crop math, Portable Text extraction, decade grouping, and initials live in
src/lib/marquee.tswith a dependency-free test script (21 assertions) — including a regression test for a real bug where rounding crop offsets and dimensions independently produced a rect 1px outside the image, which the CDN rejects.
| Movie page | Person page |
|---|---|
![]() |
![]() |
- Next.js 16 (App Router, React Server Components, Turbopack)
- Sanity Content Lake queried with GROQ over HTTP
- Tailwind CSS v4
- TypeScript, strict — and zero runtime dependencies beyond React/Next
Browser ──► Next.js (RSC, ISR 1h) ──► Sanity query API (GROQ over HTTP)
Posters ──► cdn.sanity.io (?rect=&w=&auto=format) ──► next/image optimizer
src/lib/sanity.ts— GROQ HTTP client (JSON-encoded$params, ISR caching) and the three queries: catalog, movie with cast/crew joins, person with reverse-reference filmography.src/lib/marquee.ts— pure logic:parseImageRef,cropRect,imageUrl,portableTextToPlain,groupByDecade,initials.src/components/PortableText.tsx— the dependency-free Portable Text renderer.src/app/{page,movies/[slug],people/[slug]}— server components for every route.
npm install
npm run devNo configuration needed. Tests, lint, and typecheck:
npm test # pure logic (21 assertions)
npm run lint
npx tsc --noEmitSet SANITY_PROJECT_ID and SANITY_DATASET to any dataset with public read access (or add a token header in src/lib/sanity.ts for private datasets). The GROQ queries target the standard movie-schema types (movie, person, screening) from Sanity's getting-started template.
MIT


