Installation

Get the monorepo installed and running locally, from prerequisites and private npm access to dev servers and basketball data.

2026-11-07

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 --version prints 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/media is a hard dependency of the web app's video domain, which Euro League bought, and @v4/eltv needs the Vesper SDK — so every developer machine, CI (the NPM_TOKEN GitHub 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 either npm login or set NPM_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 from stadionHQ/elb-web-tokens.
  • packages/token-parser — the tokens.json → TypeScript CLI (v4-tokens) and the Figma bridge tools.

Platform

  • packages/model — Contentful model-as-code (@v4/model).
  • packages/template-config — defineClubConfig() + the pnpm compose generator.
  • 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:

  1. packages/model/composition.json — the bought bolt-on domains.
  2. Route shims — a page.tsx for every route a composed domain's *-app package contributes, under apps/web/src/app/[locale]/.
  3. apps/web/src/lib/locale-routing.generated.ts — the locale segments from locales (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.