Installation
Get the monorepo installed and running locally, from prerequisites and private npm access to dev servers and basketball data.
Requirements
The Euro League site is one pnpm + Turborepo monorepo (the root package is named
euro-league). Before you clone it, make
sure your machine matches the versions the workspace pins, and that you can
authenticate against the private npm scope the video SDK lives on — that single
piece of setup is the one thing a plain pnpm install cannot recover from on
its own.
Toolchain versions
The versions are pinned in the root package.json, so treat this table as the
contract rather than a suggestion:
| Tool | Version | Where it is pinned |
|---|---|---|
| Node.js | ≥ 22 | engines.node in the root package.json |
| pnpm | 10.19.0 | packageManager in the root package.json |
| Git | any recent | the repo's hooks and pnpm sync-upstream |
You do not install pnpm globally. The repo declares its exact pnpm version
through the packageManager field, and Corepack
(bundled with Node) provisions it on demand:
node --version # must print v22.x or newer
corepack enable # lets the repo's pinned pnpm resolve automatically
With Corepack enabled, running any pnpm … command inside the repo uses
pnpm@10.19.0 regardless of what — if anything — you have installed globally.
Verify:
pnpm --version # 10.19.0
If
node --versionprints anything below 22, install a current LTS with your version manager (nvm install 22 && nvm use 22,fnm use 22, …) before going further. The workspace's TypeScript and Next.js versions assume Node 22 APIs.
The private npm scope (this trips up every first install)
@v4/media — the package that integrates the Stadion platform video SDK — is
the only importer of the @stadionhq-v3/platform-video-sdk-* packages, and
those packages are private on registry.npmjs.org. A plain pnpm install
with no npm authentication does not fail with a friendly message; it 404s on
@stadionhq-v3/platform-video-sdk-* partway through resolution.
There are two ways to authenticate, and you only need one.
Option A — interactive login (simplest for a developer machine). Log in as a
user who has been granted access to the @stadionhq-v3 scope:
npm login # authenticate as a user with @stadionhq-v3 access
Option B — a read-only granular token (matches what CI does). Put a
read-only token in your environment. CI authenticates the exact same scope via
the NPM_TOKEN GitHub secret — a read-only granular token written into
~/.npmrc before install — so it is the identical requirement:
# ~/.npmrc — a personal, read-only granular token with @stadionhq-v3 read scope
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
Either way, the test is the same: if pnpm install reaches the
@stadionhq-v3/* packages without a 404, your auth is good.
This repo needs a second private scope. The ELTV livestream player
(@v4/eltv) installs @dicetechnology/vesper-sdk-web, so the login or token
must ALSO read @dicetechnology — the developersv3 npm account belongs to
both organisations. CI's NPM_TOKEN and Flightcontrol's need both scopes too.
Why the token is needed everywhere.
@v4/mediais a hard dependency of the web app's video domain, which Euro League bought, and@v4/eltvneeds the Vesper SDK — so every developer machine, CI (theNPM_TOKENGitHub secret) and every Flightcontrol build must read both private scopes.
What you will have installed
The workspace is defined by pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
So a single pnpm install at the root wires up every app under apps/ and
every package under packages/ with workspace:* links — there is no
per-package install step, and nothing is published to npm.
pnpm install also runs the root prepare script, which points Git at the
repo's hooks (git config core.hooksPath scripts/hooks). The pre-commit hook
formats your staged files (scripts/hooks/format-staged.mjs) and runs the
docs-drift check over them (scripts/hooks/docs-drift.mjs --staged). Both are
advisory — the formatter fixes rather than complains, and docs drift only warns
unless you set DOCS_DRIFT=block (or skip it with DOCS_DRIFT=off); CI is the
gate. The next page walks the install and first run end to end.
Install and run
With the prerequisites in place — Node ≥ 22,
Corepack enabled, and npm authenticated against the @stadionhq-v3 and
@dicetechnology scopes — a working local environment is four steps.
1. Clone and install
git clone git@github.com:stadionHQ/publishing-hub-euro-league.git euro-league
cd euro-league
pnpm install
pnpm install resolves the whole workspace in one pass and links every
apps/* and packages/* together via workspace:*. No package has a
postinstall step, so install builds nothing. A successful run ends with pnpm's
dependency summary and no ERR_PNPM_FETCH_404.
If install 404s on
@stadionhq-v3/platform-video-sdk-*(or@dicetechnology/vesper-sdk-web) you are not authenticated against that private scope — go back to Requirements and eithernpm loginor setNPM_TOKEN. This is the single most common first-install failure.
2. Build the workspace packages once
apps/web consumes the workspace packages through their built dist/, not
their source — @v4/ghost-core's exports point at ./dist/index.js, and
@v4/model's subpaths do the same (./dictionary → ./dist/dictionary/runtime.js).
Turbo's dev task has no dependsOn: ["^build"], so pnpm dev does not build
them for you. On a fresh clone, build first:
pnpm build # everything
pnpm turbo run build --filter=@v4/web^... # or: only what the web app depends on
The same applies while you work: after changing ghost-core or model
source, rebuild that package, delete apps/web/.next and restart next dev.
Building under a live dev server poisons its cache, so stop it first:
pnpm --filter @v4/ghost-core build
rm -rf apps/web/.next
pnpm dev --filter @v4/web
3. Create your .env
The repo ships a fully annotated .env.example. Copy it to .env at the repo
root (gitignored, like every .env* except the example):
cp .env.example .env
Next.js reads env files from the app's own directory, not the repo root, so
give apps/web the root file too. A symlink keeps one copy (it is gitignored):
ln -s ../../.env apps/web/.env.local
The file is organised by concern, and its comments are authoritative. The variables you are most likely to need locally:
| Group | Variable(s) | When you need it |
|---|---|---|
| Install | NPM_TOKEN |
Only if you authenticate the private scopes by token rather than npm login. |
Contentful (apps/web) |
CONTENTFUL_SPACE_ID, CONTENTFUL_ACCESS_TOKEN |
Required: both are env.server() markers with no default. The example already holds the space, 12gd2cvfgb6b; the access token is a delivery token. |
Contentful (apps/web) |
CONTENTFUL_ENVIRONMENT |
Optional — defaults to develop. |
| Contentful model-as-code | CONTENTFUL_CMA_TOKEN |
Any @v4/model script (migrate, sync-schema, dictionary). The single management secret. |
| Competition Engine | COMPETITION_ENGINE_BASE_URL |
Defaults to http://localhost:8080. The basketball data areas read it — see Local basketball data. |
| Basketball routes | BASKETBALL_ROUTES |
false turns the data areas off (pages not found, their API routes 404). Unset or true, on. |
| Site origin | NEXT_PUBLIC_SITE_BASE_URL |
Defaults to http://localhost:3000; set on every deployed environment. |
| Video vertical | NEXT_PUBLIC_VIDEO_API_BASE_URL, NEXT_PUBLIC_BRIGHTCOVE_ACCOUNT_ID |
The /video section's platform API. |
| Figma tooling | FIGMA_PAT |
The /generate-from-figma, token-extraction, bridge and fork-diff tools. Personal access token, file-read scope. |
| Caching / revalidation | CONTENTFUL_WEBHOOK_SECRET, CACHE_REDIS_URL |
The publish webhook, and the per-client Redis on Flightcontrol. Leave CACHE_REDIS_URL empty locally (in-memory cache). |
The club-only features are off unless their variables are set, so local dev
needs none of them: WSC stories (NEXT_PUBLIC_WSC_API_KEY), the ELTV livestream
(NEXT_PUBLIC_VESPER_*), ECAL calendar buttons (NEXT_PUBLIC_ECAL_API_KEY),
measurement (NEXT_PUBLIC_GTM_CONTAINER_ID, NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID).
Cortex sign-on is the exception: .env.example ships the stage values for
CORTEX_SSO_* and NEXTAUTH_* ready to use, and CORTEX_SSO_BASE_URL is the
switch. Every variable is listed in the
environment variables reference.
The apps/web config is self-maintaining: club.config.ts declares its
needs through env.public() / env.server() markers, and the build fails
naming any missing variable — so you add exactly what the error asks for.
4. Start the dev servers
pnpm dev
pnpm dev runs turbo run dev across every app at once. The ports:
| App | URL | Script |
|---|---|---|
apps/web |
http://localhost:3000 |
next dev --webpack |
apps/docs |
http://localhost:4400 |
next dev --turbopack -p 4400 |
apps/storybook |
http://localhost:6006 |
storybook dev -p 6006 (after stage-refs) |
To run just one app, scope with --filter:
pnpm dev --filter @v4/web # web only, on :3000
pnpm dev --filter @v4/docs # these docs, on :4400
pnpm dev --filter @v4/storybook # Storybook, on :6006
Open http://localhost:3000. It redirects to /en: every page address carries
its locale segment (locales.routing: "segment" in club.config.ts), and the
middleware sends an address without one to the default's. The site boots with
the Euro League token layer (@v4/tokens-euro-league, wired in
apps/web/src/theme/tokens.css.ts) and the identity EuroLeague Basketball.
The basketball data areas (teams, players, games, …) need the Competition Engine
running — see Local basketball data,
which also covers the local production run for the Game Centre, where next dev
runs out of memory. The monorepo scripts
page covers the build / lint / test / verify commands.
The workspace at a glance
A quick map of what you just installed, so the --filter names have meaning:
Apps
apps/web— the Next.js site.apps/docs— this documentation site.apps/storybook— the single Storybook (@v4/storybook): stories, colour-set toolbar, vitest browser tests.apps/e2e— Playwright component e2e (@v4/e2e), against a Storybook build on:6006.
Design system & tokens
packages/ghost-core— the design-system core: theme foundation + components.packages/ghost-content/packages/ghost-video/packages/ghost-basketball— sibling component packages (basketball is this club's own).packages/tokens— generated Ghost token defaults + the generated icons (@v4/tokens/icons).packages/tokens-euro-league— the club token layer, vendored fromstadionHQ/elb-web-tokens.packages/token-parser— thetokens.json→ TypeScript CLI (v4-tokens) and the Figma bridge tools.
Platform
packages/model— Contentful model-as-code (@v4/model).packages/template-config—defineClubConfig()+ thepnpm composegenerator.packages/content-app/packages/video-app/packages/basketball-app— the CONTENT, VIDEO and BASKETBALL route factories.packages/media— the Stadion platform video SDK integration (the only importer of@stadionhq-v3/*).packages/competition-engine— the Competition Engine integration behind the basketball data areas.packages/image,image-contentful,image-scoreplay,image-cloudinary— the pluggable image core and its providers.packages/contentful-contracts— the field contracts the Contentful apps write and the site reads.
Club integrations
packages/wsc(WSC stories),packages/eltv(EuroLeague TV livestream),packages/ecal(add to calendar),packages/analytics(GTM + OneTrust),packages/cortex-sso(Cortex sign-on),packages/code-of-conduct(the in-arena incident report).
Tooling
packages/tickets— tickets as code and the Jira tooling.packages/docs-mcp— the house-documentation MCP server.packages/eslint-config/packages/typescript-config— the shared ESLint and tsconfig presets every package extends.
Monorepo scripts
Every day-to-day command is a root script that goes through Turborepo, so results are cached and only the packages you actually changed re-run. Run them from the repo root.
The core scripts
These map 1:1 to the scripts block in the root package.json:
| Command | What it does |
|---|---|
pnpm build |
turbo run build — build every app and package. |
pnpm lint |
turbo run lint — ESLint across the workspace. Zero warnings tolerated (packages run eslint . --max-warnings 0). |
pnpm check-types |
turbo run check-types — TypeScript across every workspace. |
pnpm test |
turbo run test — package unit suites (vitest). |
pnpm format |
prettier --write over **/*.{ts,tsx,js,mjs,json,md,css}. |
pnpm format:check |
prettier --check — the verify/CI form that fails instead of rewriting. |
pnpm compose |
Regenerate route shims + model composition from club.config.ts (see below). |
pnpm add-feature |
Add a template domain to this site — see Add a domain. |
pnpm sync-upstream |
Bring template updates into this repo as a PR — see Take template updates. |
pnpm verify |
The cheap CI gates in one command. Run this before every push. |
pnpm new-club |
The template's scaffolder that generated this repo. Not used here. |
prepare |
Runs on pnpm install: git config core.hooksPath scripts/hooks (the pre-commit format + docs-drift hook). |
Because turbo caches on inputs, a second pnpm build with nothing changed
returns almost instantly with FULL TURBO; the first run after a change rebuilds
only the affected packages and their dependents.
Scoping with --filter
Any turbo-backed script can target one package with --filter, which is how you
avoid rebuilding the world while iterating:
pnpm --filter @v4/ghost-core test # unit suite for ghost-core only
pnpm --filter @v4/ghost-core check-types # type-check one package
pnpm --filter @v4/web build # build the web app + its deps
pnpm --filter @v4/tokens generate # regenerate the token defaults
--filter accepts the package name (@v4/ghost-core), and pnpm dev --filter
narrows the dev servers exactly the same way (previous page).
pnpm verify — the pre-push gate
pnpm verify runs scripts/verify.mjs, which mirrors CI's cheap gates locally
so you never push a red build. It runs, in order, and stops at the first
failure, naming the step:
verify: prettier
verify: eslint (zero warnings)
verify: typescript
verify: unit suites
verify: figma bridges name the client's files
verify: composition drift
verify: app codegen (web)
verify: app codegen (content-app)
verify: app codegen (video-app)
verify: codegen drift
verify: all gates green
Under the hood those steps are pnpm format:check, pnpm lint,
pnpm check-types, the package unit suites
(pnpm turbo test --filter=!@v4/e2e --filter=!@v4/storybook — the same filters
as CI), node packages/token-parser/scripts/check-bridges.mjs (offline: every
component's Figma bridge names a file this site is built from),
pnpm compose -- --check, the three apps' generate codegen, and a
git diff --exit-code over the generated output. The Storybook story suite and
the e2e are not in it — they run in the nightly. If any step fails you get:
verify FAILED at: <step name>
Fix that step and re-run. It deliberately omits the network-dependent model schema sync, so it is safe to run offline.
Beyond pnpm verify, the /preflight skill runs verify plus the docs-drift
check and the cross-cutting "same commit" obligations before a PR. Docs drift is
node scripts/hooks/docs-drift.mjs (it needs ANTHROPIC_API_KEY): advisory in
the pre-commit hook, enforcing in CI.
pnpm compose — composition drift
pnpm compose reads the active club.config.ts, then materialises three things:
packages/model/composition.json— the bought bolt-on domains.- Route shims — a
page.tsxfor every route a composed domain's*-apppackage contributes, underapps/web/src/app/[locale]/. apps/web/src/lib/locale-routing.generated.ts— the locale segments fromlocales(LOCALE_SEGMENTS = { "en-US": "en" }here).
CI runs the check form, which regenerates in memory and diffs against disk:
pnpm compose -- --check # non-zero exit if the committed output is stale
So the rule is: after any change to club.config.ts, a route, or a domain, run
pnpm compose (no flag) to regenerate and commit the result — otherwise
pnpm verify (and CI) will redden on composition drift. Composing a specific
resident app rather than the default apps/web:
pnpm compose -- --app=apps/<club>
A typical loop
pnpm dev --filter @v4/web # iterate against localhost:3000
pnpm --filter @v4/web check-types # tight feedback on one package
pnpm verify # before you push
git push
For the deeper per-discipline workflows — building a component from Figma, model migrations, tests — see the build guides (Create a component, Change the content model).
Local basketball data
The basketball data areas — teams, players, coaches, games, standings and stats,
served by @v4/basketball-app — read the Competition Engine
(stadionHQ/stadion-euroleague) through @v4/competition-engine. Contentful is
not enough for them: without an engine to read, an area renders its designed
"data unavailable" state rather than data.
Run the engine
The engine runs from its own repo. Its README is the authority; the short form,
from the comment in .env.example, is:
# in a checkout of stadionHQ/stadion-euroleague
mise run up && mise run bootstrap # starts the engine on :8080
The site's club.config.ts reads the engine's origin from
COMPETITION_ENGINE_BASE_URL, which defaults to http://localhost:8080 — so a
locally running engine needs no variable at all. Point it at a hosted instance by
setting it in your .env.
When the engine cannot be read — not running, hung past ENGINE_TIMEOUT_MS
(10 seconds, packages/competition-engine/src/tags.ts) or answering an error —
the area's page renders its empty state (packages/basketball-app/src/lib/engine-failure.tsx)
and logs the failure with the address. A 404 from the engine is a missing entity
and gives the site's not-found page instead.
Turn the data areas off
BASKETBALL_ROUTES=false makes the whole basketball domain dark with no code
change (apps/web/src/lib/basketball-routes.ts): every data-area page answers
not found, their API routes (/api/players|stats|games|live/…) answer 404, the
sitemap leaves them out and the header's club-crest strip is not drawn. Unset or
true, they are on. Pages and API routes follow it at runtime (the middleware);
the header and the sitemap are cached at build, so set it where the site is
built as well as where it runs.
Replay a finished game as live
The Game Centre's live view can be watched without waiting for a real game. Set
GAME_CENTRE_REPLAY=on (development only — never on production) and open any
finished game with ?replay=10 (ten game seconds to every real one; ?replay
alone is 10). The live route then answers the game's record cut back to how far
the replay has got (packages/basketball-app/src/games/game-replay.ts).
The engine's contract and fixtures
The engine's OpenAPI contract is vendored at a pinned commit
(packages/competition-engine/spec/openapi.yaml, with spec/SOURCE naming the
repo, path and sha) and the client's types are generated from it:
pnpm --filter @v4/competition-engine check-spec # has the engine's main moved? (never writes)
pnpm --filter @v4/competition-engine sync-spec # vendor main's contract + update SOURCE
pnpm --filter @v4/competition-engine generate # regenerate src/__generated__/api.d.ts
pnpm --filter @v4/competition-engine verify # generate + fail on drift (CI)
sync-spec reads GitHub through gh. When it cannot reach GitHub it uses a
local clone of the engine repo: COMPETITION_ENGINE_REPO_DIR, defaulting to
../stadion-euroleague beside this repo.
The package's tests and stories do not need an engine. They read recorded
responses — one small real response per endpoint in
packages/competition-engine/src/__fixtures__/, exposed as typed views from
@v4/competition-engine/mocks. Re-record them from a running engine after a
contract change:
pnpm --filter @v4/competition-engine record-fixtures # from http://localhost:8080
pnpm --filter @v4/competition-engine record-fixtures --only=game,box-score # just these
The site itself never falls back to the mocks: no engine, no data. More on the package in @v4/competition-engine.
A local production run (the Game Centre)
next dev runs out of memory on the Game Centre. To work on those pages, build
the app and serve the production build instead. The web app's build script
already raises Node's heap (NODE_OPTIONS='--max-old-space-size=12288'):
pnpm --filter @v4/web build
cd apps/web && pnpm exec next start -p 3155
Then open http://localhost:3155/en, with the engine running on :8080. The build
reads the same environment as next dev, so apps/web/.env.local (the symlink
to the root .env, from Install and run)
must be in place. A .env that has grown by appending sections (the
sync-upstream blocks in .env.example are one source) can hold the same key
twice, one copy empty; if you load it into a shell yourself, make sure the
non-empty value is the one that wins.
A production build caches what next dev does not, so a change to code means a
rebuild; a change to content still reaches the page through the cache's normal
revalidation.