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.
What a Monorepo Is, and the Alternatives
| Shape | Good for | Cost | Used here |
|---|---|---|---|
| One repository per site | Sites that really are independent | The site map and design are copied into each; they drift apart | No |
| One app that reads the host name | One deployment | One build and one failure for every site | No (Chapter 2 compares it) |
| A monorepo: one repository, one app per site, shared packages | Separate builds, one shared source of truth | A shared change means rebuilding every app | Yes |
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 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:
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:
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 run | Result |
|---|---|
npm run typecheck | All 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 curl | Languages shows its five folders; web development its three; the front page lists every site |
| Links on the languages page | The seven other sites, on ports 3002 to 3008; none to itself |
| A page that does not exist | 404 |
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:
| Build | Contains “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 build | No, 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.
npm test.
Hands-On Exercises
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.
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 solutionProve 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 solutionChapter 1 Quick Reference
- Monorepo: one repository, one app per site (
apps/*), shared code inpackages/*; npm workspaces need no extra tool npm installlinks the workspaces (node_modules/@lw/…are links); 27 s, 31 packages, 369 MB for three apps- Shared package = TypeScript source,
exportspointing atsrc/index.ts, no build step - Each app needs
transpilePackages: ["@lw/sites"]innext.config.ts SiteNamecomes from the site map object, so a mistyped site name is a compile errorsiteForPaththrowsNoSiteErrorfor a path no site owns;siteUrlgives 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