@v4/video-app

The video section's route package: landing, detail, category, collection and playlist pages over ghost-video, read through @v4/media.

2026-11-07

Overview

@v4/video-app is the VIDEO domain's route package — a bolt-on domain, composed on top of the always-present content routes, and the pattern @v4/basketball-app follows. It ships the /video vertical as per-route factories over @v4/ghost-video, reads its video data through @v4/media (the platform video SDK boundary), and is mounted by the composing app through generated shims exactly like @v4/content-app.

Being a bolt-on is the point: the domain is listed in the composition (composition.json) and its routes are declared once in routes.json. On this site it is one of two bolt-on route packages, beside @v4/basketball-app. The five routes are createVideoLandingRoute, createVideoDetailRoute, createVideoCategoryRoute, createVideoCollectionRoute and createVideoPlaylistRoute, each returning a { Page, generateMetadata } object the shim instantiates once and statically re-exports.

Entry points

Subpath What it is
. The five factories and their types, parsePage and VIDEO_PAGE_SIZE (from the pure paging helpers, so a consumer of the constant does not pull in server-only), and the video card adapters videoCardList, videoCardProps and videoCardPlumbing — for a sibling route package that draws the platform's videos in its own rails (basketball's player and team pages)
./video-landing, ./video-detail, ./video-category, ./video-collection, ./video-playlist One per route factory, named by routes.json
./associated-videos What a video is about (club/0045): the videos about a player or a team and the videos that share the most with a given one — ids from Contentful, cards from the platform. Used by @v4/basketball-app's player and coach pages
./sitemap videoSitemapPaths — the video section's addresses for the app's sitemap: /video, each competition video home with a landing of its own, the landings' collections and playlists (and each collection's playlists), every category and every video. A subpath of its own so the sitemap route never loads the page components; read by apps/web/src/lib/sitemaps.ts

Dependencies

The package's runtime dependencies name its collaborators exactly:

  • @v4/media — the read path. Every factory calls configureMedia(media) and fetches through the media server fetchers (getVideoLanding, getVideoDetail, getPlayback, getCollection, getPlaylist, getLatestVideos, and for the category page getVideoPage and getVideoFilterOptions) and their normalised Media*View shapes. The package never touches the platform SDK directly — that is @v4/media's single boundary.
  • @v4/ghost-video — the page components (the listing/detail views).
  • @v4/ghost-core — shared primitives.
  • @v4/tokens — icons.
  • @v4/analytics — each page's PageAnalytics, so its page_viewed says what the page is.
  • @v4/model — the dictionary (getDictionary, from the @v4/model/dictionary subpath) and the model client every route reads through.
  • @v4/content-app — its non-route subpaths: ./metadata (the site's one page-metadata contract, pageMetadata, and the structured-data builders, so a video page publishes its title, share image, canonical and cards exactly like every other page; src/lib/metadata.ts builds the video fields), ./site-sections and ./placeholder-page (a competition's video home, src/video-detail/), ./listing-address (the category page's address shape), ./share and ./read-time. Content is always composed, so the dependency is always there.

next, react and react-dom are peer dependencies. Every route's deps in routes.json are ["client","media","locale"]: the landing resolves siteSettings.defaultVideoLandingPage and its category block, the category page reads the category's name, the detail route reads the sections snapshot, and every route, collection and playlist included, reads the listed videos' competitions (below).

Competition markers

The platform's card, hero and detail responses carry no competition, so the CompetitionMarker a video shows is its Video Item's Competition, read from Contentful by lib/video-competitions.ts — ONE query per list, for every video in it at once (get-video-competitions.graphql, tagged content:video). A video with no competition (or Corporate) shows none, and a failed lookup is no markers, never a failed page.

Owns its queries and codegen

Video's data reads come from the platform API, so its Contentful surface is small: seven queries in src/graphql/, spreading the video fragments @v4/model ships where they apply —

Query file Reads
get-video-landing-settings.graphql The landing-settings pointer (siteSettings.defaultVideoLandingPage)
get-video-landing-categories.graphql The landing's category block: every category with its newest video
get-video-category.graphql A category's name and meta title and description
get-video-category-slugs.graphql Every category's name and slug, so a page can link the category the platform names only by its label
get-video-competitions.graphql Each listed Video Item's Competition
get-video-title.graphql A video's fallback name: its Mux asset's title, when the platform sends a video with no title
get-associated-videos.graphql The association lookups behind ./associated-videos (club/0045)

It runs its own graphql-codegen against the vendored schema.graphql, with the documents glob narrowed to the video fragments so no other domain's ASTs land in its runtime gql.ts:

pnpm --filter @v4/video-app generate   # graphql-codegen → src/__generated__
pnpm --filter @v4/video-app drift       # generate + git diff --exit-code

Landing resolution and graceful degradation

The landing factory resolves its slug editors-first — siteSettings.defaultVideoLandingPage.slug, then the club-configured media.landingSlug, then "home" — and degrades to a latest-videos rail when the lookup fails or finds nothing (the why is on The landing's editor-first resolution). That degradation discipline runs through the vertical: a gated detail video degrades to poster + members badge (full entitlement UI is the auth step), and unsupported landing rails render nothing rather than breaking the page.

Where to look next

  • Routes — the routes.json registry and the five factories.
  • Sibling package: @v4/content-app — the CONTENT domain, built the same way.
  • @v4/media — the SDK boundary every factory reads through.
  • Media and video — how the media package, the video domain and the player fit together; The video section — what fans see.

Routes

routes.json is the VIDEO domain's route registry — the compose-club generator reads it to emit the composing app's shims (video is the first bolt-on route package). Each entry names the URL segment, the subpath in the exports map, the factory to call, the route params, and the deps the shim must supply. Every subpath must exist in exports, and a segment's [params] must match the factory's documented invariant.

The registry

id segment factory params deps
video-landing video createVideoLandingRoute — client, media, locale
video-collection video/collections/[collection] createVideoCollectionRoute collection client, media, locale
video-playlist video/playlists/[playlist] createVideoPlaylistRoute playlist client, media, locale
video-category video/category/[category] createVideoCategoryRoute category client, media, locale
video-category-address video/category/[category]/[...address] createVideoCategoryRoute category, ...address client, media, locale
video-detail video/[idOrSlug] createVideoDetailRoute idOrSlug client, media, locale

Each factory takes the club's media config (built from club.config), the model client and locale, and calls configureMedia(media); copy is read per request inside each Page with await getDictionary(locale), so a published label reaches the site without a deploy. Every route takes the client because every listed video's CompetitionMarker comes from its Video Item in Contentful (lib/video-competitions.ts), and some routes read more through it (below). All return a { Page, generateMetadata } route object.

createVideoLandingRoute

The /video landing (video segment, no params — the first bolt-on domain route). Resolves its slug editors-first (siteSettings.defaultVideoLandingPage → media.landingSlug → "home"), fetches the landing through getVideoLanding, and maps its rails into hero slides plus playlist/collection blocks. When no landing is configured or found, it degrades to a getLatestVideos rail so an unconfigured club still ships a working page. Needs client in addition to media/locale for the settings lookup.

A collection placed on the landing arrives from the platform as a rail of its videos, its playlists flattened. The landing reads the collection itself (getCollection) and draws it as the ELB Block-collections band: one collection card per playlist — each a series or a season — opening the collection page on that tab (/video/collections/<slug>?filter=<value>), and an "All videos" link to the collection's Show all tab (lib/collection-cards.ts). A card whose playlist has no tab of its own opens Show all. Where the collection cannot be read, the rail keeps its video cards over the collections artwork.

createVideoDetailRoute

The video detail page (video/[idOrSlug]). Loads the video with getVideoDetail; a non-gated video resolves playback through the anonymous getPlayback endpoint, while a gated video (or a 401/403) degrades to poster + members badge (entitlement UI proper is the auth step). Renders @v4/media's MediaPlayer — the hosted Mux player by default, else YouTube or Brightcove, by the playback's provider (see the media player) — passing it the media config's brightcoveAccountId and accentColor, and lists up-next cards. The route param is named idOrSlug — the shim must live in a video/[idOrSlug] segment.

A COMPETITION'S slug at that address is its video home, never a lookup for a video (the site map, 15 Oct 2026): /video is the default competition's (the landing, so /video/euroleague redirects there, 308) and /video/<competition> any other's — a TEMPORARY page (PlaceholderPageView, noindex) with every video home as its page navigation, until clips are tagged by competition. A section without video (club.config.ts sections.capabilities — the site map's Corporate) is not found there, and left out of that navigation (src/video-detail/video-home.ts). That is what the route reads the sections snapshot for, through client. How competition sections work across the site is on Competition sections.

createVideoCategoryRoute

A category's videos (video/category/[category], and video/category/[category]/[...address]), newest first, paged and filtered by season, team and competition — all in the PATH, the news listings' shape (user ruling, 15 Oct 2026; lib/category-address.ts):

/video/category/highlights
/video/category/highlights/page/2
/video/category/highlights/season/2025-26/team/zalgiris-kaunas/page/2

The pairs come straight after the category's slug in one fixed order — season, team, competition (VIDEO_CATEGORY_FILTER_KEYS) — then an optional page/<n>, with no /filter/ prefix and no query string, so every view is a static, cacheable route. A value is the choice's NAME as a slug (Zalgiris Kaunas → zalgiris-kaunas), looked up against the platform's choices, never the entry id the platform filters by.

  • The videos come from the platform through getVideoPage (/videos/latest?category=<slug>), with the list's totals for "N Videos" and the page numbers; VIDEO_PAGE_SIZE per page.
  • The filter row (Season, All Teams, the competition) comes from getVideoFilterOptions — the platform's /ContentData/* lists. A dropdown with no choices is not drawn.
  • The category itself — its name, meta title and description — comes from Contentful through client (get-video-category.graphql, tagged content:category), because the platform has no category endpoint.

The page wears the playlist page's look (BasePage-Playlist): the static header artwork, the title and "N Videos", the list under it.

createVideoCollectionRoute

The collections page (video/collections/[collection]), composed to the Ghost – Video 2.0 Collections master: back + sponsor header, WATCH LATEST (the ELB header draws no WATCH FROM BEGINNING), the featured band, series/season tabs and a videoType filter — both expressed as URL params the API filters server-side. A series tab, and the list heading under it, reads "Series 1" (video.label.series: a playlist's Series is a number in Contentful); a season tab shows the editor's text as entered ("2025-26"). The address keeps the bare value (?filter=1). Exports VIDEO_PAGE_SIZE. Pagination is delegated to the API only in the videoType-filtered branch (the platform pages videoType-scoped queries server-side, 0-based; without page/size it silently caps each group at 20, so the paged form is a correctness fix, not an optimisation); the unfiltered, playlist-grouped view is paged in-process. The route param is named collection.

createVideoPlaylistRoute

The playlist page (video/playlists/[playlist]). API-paginated: the platform pages playlists server-side 0-based, while the URL stays 1-based ?page= like every other listing. Fetches through getPlaylist and renders the shared VideoListingView. The route param is named playlist.