Add a page or route

Add a route to a domain's route package: the factory, routes.json, its dependencies and the generated shim the app mounts.

2026-11-07

Route factories

The site's pages are composed, not hand-wired. apps/web authors no page bodies of its own; each domain ships a *-app package of per-route factories, declares them in a routes.json registry, and the compose-club generator materialises the thin page.tsx shims the app actually mounts — one per route of every domain club.config.ts lists. Why the app is built this way is on Routes and factories and Bolt-on packages; this guide is the mechanics you work with when you add a route.

Three route packages are composed here: @v4/content-app (the CONTENT domain — home, news listings, articles, category, tag, content page), @v4/video-app (the VIDEO domain — landing, collection, playlist, category, detail) and @v4/basketball-app (this project's own BASKETBALL domain — teams, players, coaches, games, standings, stats). A new domain follows the same shape (Add a domain).

A route is a factory

Each route is a function that takes the app's dependencies and returns a Page and a generateMetadata — both server components. The factory holds the Contentful query, the transform, the dictionary and the date formatting; the composing app supplies only the client, the locale, and — where a route needs them — the video platform's media config and the Competition Engine settings.

// packages/content-app/src/news-article/index.tsx (abridged — the real factory
// also dispatches on the article's format, builds the related rail and JSON-LD)
import "server-only";
import { cache } from "react";
import { articlePageFromFragment } from "@v4/model";
import { getDictionary } from "@v4/model/dictionary";
import type { createModelClient, DictionaryLocale } from "@v4/model";
import { GetArticlePageBySlugDocument } from "../__generated__/graphql";
import { CONTENT_CACHE } from "../lib/cache";

export type ContentClient = ReturnType<typeof createModelClient>;

export interface NewsArticleRouteConfig {
  /** The club's model client (the app builds it from club.config). */
  client: ContentClient;
  /** The club's default locale — drives the dictionary and date formatting. */
  locale: DictionaryLocale;
}

export interface NewsArticlePageProps {
  params: Promise<{ article: string }>;
}

export function createNewsArticleRoute({ client, locale }: NewsArticleRouteConfig) {
  // React cache(): generateMetadata and Page share ONE Contentful request per
  // render pass (an un-cached fetch would run twice per request).
  const loadArticle = cache(async (slug: string) => {
    const result = await client.query(
      GetArticlePageBySlugDocument,
      { slug, preview: false },
      CONTENT_CACHE.articleDetail(slug),
    );
    const item = result.articlePageCollection?.items[0];
    return item ? articlePageFromFragment(item) : null;
  });

  return {
    async generateMetadata({ params }: NewsArticlePageProps) {
      const { article } = await params;
      const view = await loadArticle(article);
      return view ? { title: view.meta.title } : {};
    },
    async Page({ params }: NewsArticlePageProps) {
      const { article } = await params;
      const view = await loadArticle(article);
      if (!view) notFound();
      // the live labels, cached and tagged content:dictionaryKey
      const t = await getDictionary(locale);
      return <ArticlePageView header={/* … */} body={/* … */} />;
    },
  };
}

Every factory is exported from the package's src/index.ts and from a subpath in package.json exports (the subpath is what the shim imports):

// packages/content-app/package.json
"exports": {
  ".": "./src/index.ts",
  "./news-article": "./src/news-article/index.tsx",
  "./home": "./src/home/index.tsx"
  // … one per route
}

news-article is no longer a route of its own: articles share the /news/<x> addresses with the competitions' listings, so the news-path route (news/[...path], createNewsPathRoute) tries the leading segments as a listing's address first and hands a single unclaimed segment to createNewsArticleRoute. A factory can compose another factory this way.

The generator invariant. A factory that reads a route param documents which param it expects (params: Promise<{ article: string }>), and the shim must live in the matching [article] dynamic segment. compose-club enforces this — a segment param missing from the route's declared params throws at compose time, so the invariant can never break silently.

routes.json — the registry

Each *-app package carries a routes.json at its root. The generator reads it to know what to build. Per route:

// packages/content-app/routes.json (abridged)
{
  "domain": "content",
  "routes": [
    {
      "id": "news-listing-paged",
      "segment": "news/page/[page]",
      "subpath": "./news-listing",
      "factory": "createNewsListingRoute",
      "params": ["page"],
      "deps": ["client", "locale"],
      "boundary": "news",
    },
    {
      "id": "news-path",
      "segment": "news/[...path]",
      "subpath": "./news-path",
      "factory": "createNewsPathRoute",
      "params": ["...path"],
      "deps": ["client", "buildClient", "locale"],
      "staticParams": true,
      "boundary": "news",
    },
  ],
}
Field Meaning
id Stable route id — appears in the shim header and drives pruning
segment The app-relative route path, with [params] and [...catchAll]
subpath The package export the factory lives at — must exist in the exports map
factory The exported factory name — must exist in that module
params Every dynamic segment param, so the generator can check the segment
deps The dependencies the shim wires in — see DEP_WIRING below
pageProps Optional: the shim forwards Next's page props although the segment has no param (a bare area address whose route still reads params.path)
loading Optional { subpath, export }: the package's own skeleton, rendered as the shim's <Suspense> fallback and exported by a generated loading.tsx beside the shim
staticParams Optional: the shim re-exports the factory's generateStaticParams, so the pages it lists prerender at build
boundary Optional: the segment whose generated layout holds the boundary — see below

The generator validates all of it loudly: a subpath absent from the exports map, a factory name not found in the module, or a segment [param] missing from params each fail the compose. A -paged route reuses its base factory (same subpath); the factory reads the optional page segment, so path-based pagination keeps every listing page a static route.

deps and DEP_WIRING

Each entry in a route's deps maps, through DEP_WIRING in packages/template-config/scripts/compose-club.ts, to the imports and props the generated shim passes to the factory:

dep shim imports passed as
client contentClient from src/lib/content client: contentClient
deliveryClient deliveryContentClient from src/lib/content client: deliveryContentClient
buildClient deliveryContentClient from src/lib/content buildClient: deliveryContentClient
locale clubConfig from club.config locale: clubConfig.locales.default
media mediaSettings from src/lib/media media: mediaSettings
sportsData sportsDataSettings from src/lib/sports-data sportsData: sportsDataSettings
competitionEngine competitionEngineSettings from src/lib/competition-engine competitionEngine: competitionEngineSettings

contentClient consults draft mode whenever a preview token is configured, which makes every page it renders request-time. deliveryClient hands the route the delivery-only client instead, for a route that reads nothing an editor previews and must stay cacheable (the basketball data areas); buildClient passes it alongside for what a route reads at build time in generateStaticParams, where Next refuses draftMode().

A new kind of dependency means extending DEP_WIRING (and the app lib/ module it points at) — never hand-editing a generated shim. All six video routes declare ["client", "media", "locale"]; the basketball routes declare ["deliveryClient", "locale", "competitionEngine"], plus media where the page shows video.

The generated shim

compose-club writes one page.tsx per composed route. It is machine-generated and carries a header — never edit it:

Shims live under apps/web/src/app/[locale]/, because the site's locales.routing is "segment" (every address carries the locale, /en/…):

// apps/web/src/app/[locale]/video/[idOrSlug]/page.tsx
// GENERATED by compose-club — do not edit. route:video-detail (video domain, @v4/video-app)
// Regenerate with `pnpm compose`; drift fails CI.
import { createVideoDetailRoute } from "@v4/video-app/video-detail";
import { Suspense, type ReactNode } from "react";
import clubConfig from "../../../../../club.config";
import { contentClient } from "../../../../lib/content";
import { mediaSettings } from "../../../../lib/media";
import RouteLoading from "../../route-loading";

const route = createVideoDetailRoute({
  client: contentClient,
  media: mediaSettings,
  locale: clubConfig.locales.default,
});

export default function Page(props: Parameters<typeof route.Page>[0]): ReactNode {
  return (
    <Suspense fallback={<RouteLoading />}>
      <RoutePageBody {...props} />
    </Suspense>
  );
}

async function RoutePageBody(props: Parameters<typeof route.Page>[0]): Promise<ReactNode> {
  return route.Page(props);
}

export const generateMetadata = route.generateMetadata;

Suspense boundaries and the loading placeholder

Under cacheComponents a route must access runtime/uncached data inside a <Suspense> boundary to prerender a static shell, so compose-club wraps a shim's Page when the route has [x] params (runtime) OR a media, sportsData or competitionEngine dep, and names no boundary (the video routes fetch the platform API uncached; content fetches go through the use-cache-wrapped contentClient and need no boundary). A route with a loading entry uses the package's skeleton as the fallback (<Loading locale={clubConfig.locales.default} />) instead of RouteLoading, and a route with staticParams also re-exports generateStaticParams. A param route's wrapper forwards props (as above); a media-only route with no params (the video landing) gets a props-less Page() wrapper. A route that is neither (home, the /news base — cached content, no params) keeps the bare export default route.Page; form.

The wrapper's fallback is the app's full-viewport placeholder (apps/web/src/app/[locale]/route-loading.tsx), never null. The shim's boundary is the nearest one to the runtime read, so its fallback is what the prerendered shell — the page's first paint — holds between the header and the footer; an empty fallback put the footer straight under the header until the page streamed in. The loading.tsx files render the same box, but they sit outside the shim's boundary and are already resolved in that shell. The box keeps the competition the visitor is in until the page declares its own (KeepPageCompetition — only where the app has marked the address as the page's to decide, competitionFrom), and a route package's own loading state must render it too, as the data areas' skeletons do. ScrollManager waits (for up to three seconds) for the box to go before it restores a saved scroll position, because until then the document's height is the box's, not the page's. On a forward in-app navigation it takes the page to the top in the same commit that swaps the box in (a layout effect): the window still holds the previous page's offset there, which on the shorter placeholder page is the footer, and left to a passive effect the browser painted it for a frame.

The News boundary

The News routes are the exception. Every News route with params declares "boundary": "news", and their shims keep the bare export default route.Page; form. Next re-creates every boundary below the part of the page a navigation changes, so a boundary in a shim, a loading.tsx in a segment, or one beside a layout above it showed the placeholder on every filter, season, team, competition and page chosen, and on every article opened from a listing: the page vanished until the next one streamed in. compose-club generates one news/layout.tsx instead, whose <Suspense> holds the same placeholder. It is on screen for every navigation within News, so the current page stays until the next is ready, while a full load, and arriving from another section (Home, Video), still open on the placeholder. The competitions' listings share news/[...path] with the articles, which is why the boundary sits at the section, not at each listing. No loading.tsx may sit under that layout, and no News page may have a boundary of its own; apps/web/src/app/[locale]/route-shims.test.ts fails on either. The generator refuses a boundary that is not an ancestor of the route's segment, the root, and a segment where the app has a layout of its own.

Back navigation and the saved scroll offset

A Back within the app restores the offset saved for the page. Next keeps the page it leaves hidden, and hiding runs the page's effect cleanups: an embed that builds its content in an effect empties itself there and builds again only when the page is shown. When a Back or Forward shows the page, an X post and a script embed keep the height they last had on it, in a column of the same width, from the commit that shows the page until their content is as tall again; one that has not come back after ten seconds gives the room up once it is below the screen or the reader is at the top of the page (useKeptHeight, @v4/content-app lib/kept-height.ts). A navigation of the reader's own starts at the top and keeps nothing. Without that the page came back shorter above the reader, and the offset landed on the content further down, about 950px with two embeds. The WSC widget empties its mount the same way and keeps nothing: harmless in its row layouts, whose wells have fixed heights, but a grid layout is sized by its content, so a Back below one can still land off. Any other component that builds its height in an effect and empties it in the cleanup needs useKeptHeight too. ScrollManager also saves the offset as the reader taps a link or a button, not only when a scroll settles: a tap inside the scroll save's 120ms debounce changed the address first, and that offset was lost.

Pruning and the drift check

A header-marked shim whose route is no longer composed is pruned; a hand-written page (no header) is never touched. --check diffs the regenerated output against disk and runs inside pnpm verify, so composition drift fails CI.

Next

Adding a route — write a factory, register it, and compose.

Adding a route

This page walks the full flow for adding a route to an existing domain's *-app package — say a news/authors/[author] listing to @v4/content-app. The mechanics are the same for any *-app package; the basketball data areas add a few of their own (Data-area routes).

⚡ Do it with Claude — /new-route scaffolds the factory, the exports, the routes.json entry, the query and its codegen, and runs pnpm compose.

1. Write the factory

Create the route directory and its index.tsx factory beside the existing ones, following the shape on the route factories page. A factory:

  • is "server-only",
  • takes a config object of exactly the deps it needs ({ client, locale }),
  • wraps its Contentful load in React cache() so Page and generateMetadata share one request,
  • reads its page-level query by spreading @v4/model fragments (the query lives in the app package's src/graphql/, never raw field selections — extend a fragment instead if a field is missing),
  • returns { Page, generateMetadata }.
// packages/content-app/src/news-author/index.tsx
import "server-only";
import { cache } from "react";
import { notFound } from "next/navigation";
import { authorListingFromFragment } from "@v4/model";
import { getDictionary } from "@v4/model/dictionary";
import type { createModelClient, DictionaryLocale } from "@v4/model";
import { GetAuthorListingDocument } from "../__generated__/graphql";
import { CONTENT_CACHE } from "../lib/cache";

export type ContentClient = ReturnType<typeof createModelClient>;

export interface NewsAuthorRouteConfig {
  client: ContentClient;
  locale: DictionaryLocale;
}

export interface NewsAuthorPageProps {
  params: Promise<{ author: string }>;
}

export function createNewsAuthorRoute({ client, locale }: NewsAuthorRouteConfig) {
  const load = cache(async (slug: string) => {
    const result = await client.query(
      GetAuthorListingDocument,
      { slug, preview: false },
      CONTENT_CACHE.authorListing(slug),
    );
    const item = result.authorCollection?.items[0];
    return item ? authorListingFromFragment(item) : null;
  });

  return {
    async generateMetadata({ params }: NewsAuthorPageProps) {
      const { author } = await params;
      const view = await load(author);
      if (!view) return {};
      const t = await getDictionary(locale);
      return { title: t("author.listing.title") };
    },
    async Page({ params }: NewsAuthorPageProps) {
      const { author } = await params;
      const view = await load(author);
      if (!view) notFound();
      return <ListingView /* … */ />;
    },
  };
}

CONTENT_CACHE.authorListing does not exist yet: every new query gets its own row in packages/content-app/src/lib/cache.ts — its tags and its cache profile (Caching and revalidation → Adding a surface). getDictionary is async and reads the live labels; call it inside Page and generateMetadata, never at factory time.

2. Export the factory

Add it to the package's src/index.ts and to the exports map in package.json. The subpath you add here is the exact string the routes.json subpath must match:

// packages/content-app/src/index.ts
export { createNewsAuthorRoute } from "./news-author";
export type { NewsAuthorRoute, NewsAuthorRouteConfig, NewsAuthorPageProps } from "./news-author";
// packages/content-app/package.json → "exports"
"./news-author": "./src/news-author/index.tsx"

3. Register in routes.json

Add the route entry. The segment [param] must appear in params, the subpath must be in the exports map, and the factory must be the exported name — the generator checks all three:

// packages/content-app/routes.json → "routes"
{
  "id": "news-author",
  "segment": "news/authors/[author]",
  "subpath": "./news-author",
  "factory": "createNewsAuthorRoute",
  "params": ["author"],
  "deps": ["client", "locale"],
  "boundary": "news",
}

Every News route with params declares "boundary": "news" and must not get a loading.tsx: the section's one generated news/layout.tsx holds the boundary, and apps/web/src/app/[locale]/route-shims.test.ts fails on a News route with a boundary of its own.

If the listing paginates, add a second -paged entry that reuses the same subpath and factory with segment: "news/authors/[author]/page/[page]" and params: ["author", "page"] — the factory reads the optional page segment.

4. Compose

pnpm compose               # regenerate composition.json + the route shims
pnpm compose -- --check    # regenerate in memory, diff against disk (the CI drift gate)

pnpm compose reads apps/web/club.config.ts, resolves the composed domains through the model's own ordering rules, and writes the new page.tsx shim under apps/web/src/app/[locale]/news/authors/[author]/. Commit the regenerated shim alongside your package changes — --check runs inside pnpm verify, so a missing regeneration fails CI.

This repo has one app, apps/web, which pnpm compose targets by default; composition.json still records it as a per-app map (web).

5. Verify

pnpm --filter @v4/content-app generate      # codegen for the new query
pnpm --filter @v4/content-app check-types
pnpm --filter @v4/content-app test
pnpm verify   # includes the compose --check drift gate and the app codegen drift

A fixed segment under news/ takes that address from everything else that could claim it: news/[...path] reads /news/<x> as a competition's listing or an article, so a competition, category or article slugged authors would become unreachable. Add the segment to NEWS_FIXED in packages/content-app/scripts/check-addresses.mts and run the check against a real space:

pnpm --filter @v4/content-app check-addresses -- --env=develop

What not to do

  • Never edit a generated shim. It carries a GENERATED by compose-club header and is overwritten on the next compose; drift fails CI.
  • Never write raw GraphQL field selections in the app for a model-owned surface. Spread a @v4/model fragment; if a field is missing, extend the fragment (reviewed) — see Change the content model → Fragments and types.
  • Never wire a new dependency by hand into a shim. Extend DEP_WIRING in compose-club.ts and the app lib/ module it points at.
  • .claude/rules/model/contentful-model.md — fragments, transforms, the app-vs-package query boundary.
  • Add a domain — when the route belongs to a domain that does not exist yet.

Data-area routes

The basketball data areas — teams, players, coaches, games, standings and stats — are @v4/basketball-app's routes (packages/basketball-app/routes.json). They follow the factory model above, with a shape of their own that a new area, or a new page in an existing one, must keep.

Two routes per area, one factory

Each area registers a bare segment and a catch-all over the SAME factory:

// packages/basketball-app/routes.json (one area of six)
{
  "id": "teams",
  "segment": "teams",
  "subpath": "./teams",
  "factory": "createTeamsRoute",
  "params": [],
  "deps": ["deliveryClient", "locale", "competitionEngine", "media"],
  "pageProps": true,
  "loading": { "subpath": "./loading", "export": "TeamsLoading" },
},
{
  "id": "teams-path",
  "segment": "teams/[...path]",
  "subpath": "./teams",
  "factory": "createTeamsRoute",
  "params": ["...path"],
  "deps": ["deliveryClient", "locale", "competitionEngine", "media"],
  "loading": { "subpath": "./loading", "export": "TeamPathLoading" },
  "staticParams": true,
}
  • pageProps: true on the bare route: its Page still reads params.path (absent there), so the shim forwards Next's props instead of calling route.Page() bare. The props stay REQUIRED in the factory's types — an optional argument fails Next's build-time page check.
  • deliveryClient, not client. Nothing an editor previews is read here, so the area takes the delivery-only client and its pages stay cacheable with preview switched on.
  • competitionEngine declares the Competition Engine dependency. The package is configured as a side effect of the shim importing apps/web/src/lib/competition-engine.ts; the factory takes the value so the dependency is declared, as it does media (standings and stats show no video and omit it).
  • loading is the area's own skeleton (packages/basketball-app/src/loading/), used as the shim's <Suspense> fallback and as the generated loading.tsx. It must render the competition the visitor is in, as route-loading.tsx does.
  • staticParams: true on the catch-all. Under Cache Components a catch-all with no build-time list reads its params at request time and its body is never cached. The factories list ONE placeholder address (PRERENDER_PARAMS, lib/prerender.ts), answered not found before any engine read, so the build never needs the Competition Engine and every real address is cacheable from its first visit.

Addresses

resolveAreaAddress (packages/basketball-app/src/lib/area.ts) turns the segments after the area into one of:

  • a redirect — the bare /<area> goes to the default competition's list; a differently-cased address is a 308 to the lowercase one; the current season's long address is a 308 to the bare list;
  • a list — /<area>/<competition> plus the filters LIST_FILTERS allows, as <key>/<value> pairs in order: teams season; players season, team, page; coaches none; games season, then round (a round only within a season); standings season; stats season. Filters are path segments, never searchParams;
  • an entity — /<area>/<slug> for a team, player, coach or game, and the pages ENTITY_PAGES declares under it (teams: ["roster"] → /teams/<team>/roster). Nothing else after an entity's slug is a page.

Standings and stats also take view and tab segments (standings/view-path.ts, stats/tab-path.ts). A team or game slug that equals a competition's slug is read as the competition's list first — pnpm --filter @v4/content-app check-addresses -- --env=develop reports every such clash in a real space.

The area-route fallback

createAreaRoute (lib/area-route.tsx) is the original TEMPORARY page for an area: its header, the competitions as page navigation and a note (PlaceholderPageView from @v4/content-app), noindex. The real pages replaced it for teams, players, games, standings and stats; the coaches LIST still renders it (createCoachesRoute falls back to it), while a coach's own page is real. A new area can start on it at its final address and replace it page by page.

Adding a page to an area

  1. Read it through @v4/competition-engine's server fetchers and views, never with a raw fetch — their caching is described in Caching and revalidation → Competition Engine data.
  2. Editorial fields (a team's page, hero slides, a player's profile) come from Contentful through the @v4/model/basketball fragments and the package's own queries in packages/basketball-app/src/graphql/ (pnpm --filter @v4/basketball-app generate).
  3. A new filter or entity page goes in LIST_FILTERS / ENTITY_PAGES, with a unit test in area.test.ts.
  4. A new area also needs SPORT_AREAS (packages/model/src/core/sections.ts), two routes.json entries, a segment layout under apps/web/src/app/[locale]/<area>/ that imports tokens-basketball.css (and tokens-video.css if it shows video), and pnpm compose. See Competition sections for how the areas relate to the competitions.