Monorepo for Many Sites

Learning Website with Next.js

Chapter 1 · Monorepo for Many Sites

The notes are going to become several sites, one for each part: languages, web development, programming and so on. Learning Website: Framework & Architecture 2 decided which folders belong to which site, and Learning Website with Django 1 built that as one Django project. This course builds the same thing with Next.js, so the two can be compared fairly. This first chapter sets up the shape: one repository, one small app for each site, and one shared package that holds the site map.

Run for real, on Node 24, npm 11, Next.js 16.4, React 19.3 and TypeScript 5.9
Everything below was installed, tested, type-checked, built and served on this machine, with three apps (portfolio, languages, web development). It was not opened in a browser or run on the server.

What a Monorepo Is, and the Alternatives

ShapeGood forCostUsed here
One repository per siteSites that really are independentThe site map and design are copied into each; they drift apartNo
One app that reads the host nameOne deploymentOne build and one failure for every siteNo (Chapter 2 compares it)
A monorepo: one repository, one app per site, shared packagesSeparate builds, one shared source of truthA shared change means rebuilding every appYes

The Layout

learning-website-nextjs/
├── package.json            workspaces: apps/*, packages/*
├── tsconfig.base.json      one set of TypeScript rules for every app
├── apps/
│   ├── portfolio/          the front page of osztromok.com (port 3000)
│   ├── languages/          languages.osztromok.com        (port 3001)
│   └── webdevelopment/     webdevelopment.osztromok.com   (port 3002)
└── packages/
    └── sites/              the site map and address helpers, shared by every app
        └── src/
            ├── index.ts
            └── index.test.ts

npm workspaces make this work with no extra tool. The root package.json lists the folders that are packages, and npm install links them to each other instead of downloading them: in node_modules/@lw/ there are four links to the real folders, so an app that imports @lw/sites gets the live source file, not a copy. The install took 27 seconds and added 31 packages, with 0 vulnerabilities reported.

node_modules is 369 MB
That is for three apps, and it is why node_modules is never committed, and why the install is best done outside a synchronised folder such as OneDrive. pnpm would use far less disk (it shares one copy of each package); it was not run here, so that is the documented behaviour, not a measurement.

The Shared Package: the Site Map

The one thing every site must agree on is which folders belong to which site. It lives in packages/sites as TypeScript source with no build step: its exports field points straight at src/index.ts. It is the Python site map from the Django project, written again in TypeScript:

export const SITES = { languages: { title: "Languages", folders: ["france", "germany", "hungary", "japan", "culture"] }, webdevelopment: { title: "Web Development", folders: ["web-development", "web-platforms", "web-servers"] }, // ... six more sites: 8 sites and 48 folders in all } as const; export type SiteName = keyof typeof SITES; export function siteForPath(path: string): SiteName { /* the first folder decides; sidebar pages follow their subject */ } export function siteUrl(site: SiteName, env: Environment, path = "/"): string { /* dev or prod address */ }

Because SiteName is made from the object itself, a typo such as "lanugages" is a compile error everywhere it is used. The function siteForPath fails loudly with a NoSiteError for a path that no site owns, instead of guessing. Seven tests check that no folder belongs to two sites, that there are 48 folders, the sidebar and special-prefix rules, the addresses, and that every site has its own development port. They use Node's own test runner, which runs TypeScript files directly, so no test library was added. All 7 pass. (The first run of npm test failed only because the command named a folder instead of a file pattern.)

Is It the Same Map as the Python One?

A one-off script runs the Django project's Python site map, prints it as JSON and compares it key by key with the TypeScript one. Result: identical, 8 sites and 48 folders. That also exposes a weakness: there are now two copies of the map, and a person has to remember to compare them. The script is not part of npm test because it needs the Django project. In the final site only one framework would be used, which removes the problem.

One App per Site

Each app has its own package.json, its own port and its own build. The only unusual line is in its next.config.ts:

const config: NextConfig = { // the shared package is TypeScript source, so Next has to compile it too transpilePackages: ["@lw/sites"], };

Next compiles the app's own files but treats packages in node_modules as already compiled, so without that line the build stops at the package's first export. Links from one site to another come from siteUrl(), so one place decides whether a link is http://systems.localhost:3004/ (development) or https://systems.osztromok.com/ (set LW_ENV=prod when building for the real domain).

Real runResult
npm run typecheckAll three apps pass
npm run build:languages (one app, cold)Compiled in 2.6 s; 3 static pages; 11.4 s in all
npm run build (all three)18.6 s
Start each app and fetch it with curlLanguages shows its five folders; web development its three; the front page lists every site
Links on the languages pageThe seven other sites, on ports 3002 to 3008; none to itself
A page that does not exist404

The pages are static: they were produced at build time and need no server code to answer. The names *.localhost were not opened in a browser; the requests went to localhost, and the host name does not matter because each app serves only its own site.

The Price of Sharing

A shared package is shared source, not a running service. To see what that means, I changed the title “Languages” to “Languages (changed)” in the package and rebuilt only the languages app:

BuildContains “Languages (changed)”?
Languages (rebuilt)Yes
Web development (not rebuilt)No: its output still has the old title
All three, after changing it back and running npm run buildNo, in every app (0 matches)

A change to the package reaches an app only when that app is rebuilt and redeployed, so after changing the package, every app that uses it must be rebuilt, or the sites disagree. For three apps that is one command (18.6 s). For eight it would be roughly 50 s, extrapolated and not measured. A tool such as Turborepo can skip the apps a change cannot affect; it was not used or run here, so that is what it is for, not a tested recommendation.

What was not verified
No browser was used and nothing ran on the server. Only three of the eight apps exist; the other five are copies of the languages app with another name and port. pnpm and Turborepo were not tried. The TypeScript and Python site maps are compared by a script, not by a test that runs with npm test.

Hands-On Exercises

Exercise 1

Create an npm workspace with a shared @lw/sites package holding the site map in TypeScript, with tests that run without a test library. Install it and report the size and the test result.

📄 View solution
Exercise 2

Add three Next.js apps that import the shared package, type-check and build each one alone and all together, start them on separate ports and fetch them. State what was and was not checked.

📄 View solution
Exercise 3

Prove the TypeScript site map equals the Python one, then change the shared package and show which apps notice. Explain the cost of sharing and what you chose not to try.

📄 View solution

Chapter 1 Quick Reference

  • Monorepo: one repository, one app per site (apps/*), shared code in packages/*; npm workspaces need no extra tool
  • npm install links the workspaces (node_modules/@lw/… are links); 27 s, 31 packages, 369 MB for three apps
  • Shared package = TypeScript source, exports pointing at src/index.ts, no build step
  • Each app needs transpilePackages: ["@lw/sites"] in next.config.ts
  • SiteName comes from the site map object, so a mistyped site name is a compile error
  • siteForPath throws NoSiteError for a path no site owns; siteUrl gives the dev or prod address (LW_ENV=prod)
  • Tests: Node's own runner (node --test "…/*.test.ts"), 7 pass; the TypeScript and Python site maps are identical (8 sites, 48 folders)
  • Builds: one app 11.4 s from cold, all three 18.6 s; pages are static
  • A change to a shared package reaches an app only when that app is rebuilt: rebuild every app
  • Not tried: pnpm, Turborepo, a browser, the server; five of the eight apps