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.
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-routescaffolds the factory, the exports, theroutes.jsonentry, the query and its codegen, and runspnpm 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()soPageandgenerateMetadatashare one request, - reads its page-level query by spreading
@v4/modelfragments (the query lives in the app package'ssrc/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-clubheader 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/modelfragment; 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_WIRINGincompose-club.tsand the applib/module it points at.
Related
.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: trueon the bare route: itsPagestill readsparams.path(absent there), so the shim forwards Next's props instead of callingroute.Page()bare. The props stay REQUIRED in the factory's types — an optional argument fails Next's build-time page check.deliveryClient, notclient. Nothing an editor previews is read here, so the area takes the delivery-only client and its pages stay cacheable with preview switched on.competitionEnginedeclares the Competition Engine dependency. The package is configured as a side effect of the shim importingapps/web/src/lib/competition-engine.ts; the factory takes the value so the dependency is declared, as it doesmedia(standings and stats show no video and omit it).loadingis the area's own skeleton (packages/basketball-app/src/loading/), used as the shim's<Suspense>fallback and as the generatedloading.tsx. It must render the competition the visitor is in, asroute-loading.tsxdoes.staticParams: trueon 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 filtersLIST_FILTERSallows, as<key>/<value>pairs in order: teamsseason; playersseason,team,page; coaches none; gamesseason, thenround(a round only within a season); standingsseason; statsseason. Filters are path segments, neversearchParams; - an entity —
/<area>/<slug>for a team, player, coach or game, and the pagesENTITY_PAGESdeclares 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
- Read it through
@v4/competition-engine's server fetchers and views, never with a rawfetch— their caching is described in Caching and revalidation → Competition Engine data. - Editorial fields (a team's page, hero slides, a player's profile) come from
Contentful through the
@v4/model/basketballfragments and the package's own queries inpackages/basketball-app/src/graphql/(pnpm --filter @v4/basketball-app generate). - A new filter or entity page goes in
LIST_FILTERS/ENTITY_PAGES, with a unit test inarea.test.ts. - A new area also needs
SPORT_AREAS(packages/model/src/core/sections.ts), tworoutes.jsonentries, a segment layout underapps/web/src/app/[locale]/<area>/that importstokens-basketball.css(andtokens-video.cssif it shows video), andpnpm compose. See Competition sections for how the areas relate to the competitions.