All writing

· 5 min read ·

Two sites, one Bun workspace

The RabtX site and my portfolio were two repos that kept growing the same components. Moving them into one Bun workspace took an evening, and most of the lessons came from the upgrades, not the merge.

I built rabtx.dev and my portfolio at about the same time, from the same Figma file, in two separate repos. Before long each had its own copy of a Markdown renderer, a post loader, a project dialog, a screenshot carousel and a theme toggle, and each copy had drifted a little. The versions had drifted too: one site was on Next.js 16.2, the other on 16.4, with different React and Bun versions underneath. Two small sites do not need a monorepo. But two small sites that keep copying each other do.

Keep the history

I did not want to start from a fresh commit and lose the story of either site. git subtree imports a repo into a folder and keeps every commit:

git init -b main rabtx-web && cd rabtx-web
git commit --allow-empty -m "chore: start the rabtx web monorepo"
git subtree add --prefix=apps/landing ../rabtx-landing main
git subtree add --prefix=apps/portfolio ../porfolio-blog main

Both old main branches end up as ancestors of the new one. That had a nice side effect: pushing to the old landing repo was a normal fast-forward, not a force push. I renamed that repo on GitHub, pushed, and every old link now redirects.

One thing caught me out. My local copies were behind GitHub, by twelve merged pull requests on one side and one on the other. The first push was rejected, which is exactly what you want. git subtree pull brought both up to date, with three small conflicts to resolve by hand.

One list of versions

Bun workspaces support a catalog: every dependency version is written once, in the root package.json, and each app points at it.

{
  "workspaces": {
    "packages": ["apps/*", "packages/*"],
    "catalog": {
      "next": "16.4.4",
      "react": "19.3.1",
      "tailwindcss": "^4.3.3"
    }
  }
}
{ "dependencies": { "next": "catalog:", "react": "catalog:" } }

Now an upgrade is one line, and the two sites cannot drift apart again. There is one bun.lock at the root, and Bun installs each app's packages as links into one shared store, so the second app costs almost nothing on disk.

What I did not share is the look. Each site keeps its own globals.css and colour tokens. Shared components should read semantic variables like --surface and --line, and each site decides what those mean.

What the upgrades broke

I moved everything to the latest versions in the same pass. Three things broke, and none of them were the merge:

  • TypeScript 7 and the linter. TypeScript 7 is the new native compiler, and typescript-eslint refuses to run against it yet. Linting simply stopped. I pinned TypeScript to 6.0.3, the latest 6.x, and left a note to try 7 again when the linter catches up.
  • Lucide 1.0 dropped brand icons. Github and Linkedin no longer exist in lucide-react. The portfolio would not have built. I drew the two icons locally with the same outlined paths, so nothing on the page changed.
  • Route types. PageProps and LayoutProps are generated by Next.js. A plain tsc --noEmit on a fresh checkout cannot find them. The typecheck script now runs next typegen first.

None of these were hard. All of them would have been confusing if I had upgraded and merged in separate weeks and tried to remember which change caused what.

Then make it faster

With both sites in one place, I measured them together with Lighthouse on a throttled mobile profile. The home page of rabtx.dev was already fine, but the product pages, the posts and the portfolio all had a largest paint around three seconds, and the portfolio home weighed more than 650 KB.

The fixes were small and mostly about images:

  • Load the largest image first. On a product page the largest element is the first screenshot, and it was marked loading="lazy". On a post it is the cover, which was loaded but not marked as urgent. Both now load eagerly with fetchPriority="high", and so does the first project card on the portfolio.
  • Preload only the text font. Each page preloaded every font it used, so a handwriting face and a mono face for small labels competed with the first screenshot. Now only the body font is preloaded; the others swap in a moment later.
  • Send the size you show. The portfolio's project cards showed 290-pixel-wide crops of 1440-pixel screenshots. Cards now get 800-pixel copies, and the full screens load only when you open a project. The avatar went from a 63 KB PNG to a 2 KB WebP, and the favicon from 80 KB to 14 KB.
  • Keep data on the server. The products section on rabtx.dev was a client component, so every product's full write-up shipped as JavaScript just to power a dialog. The cards now render on the server, and the dialog receives only the fields it shows.

The results, from the same throttled mobile run before and after:

PageScoreLargest paintPage weight
rabtx.dev product page91 → 953.4 s → 2.6 sabout the same
rabtx.dev post94 → 972.8 s → 2.2 sabout the same
Portfolio home89 → 943.5 s → 2.8 s668 KB → 389 KB

The home page of rabtx.dev stayed at 98. None of this needed a new library, just reading the Lighthouse breakdown and believing it.

Would I do it again?

Yes, with one rule: merge, upgrade and measure in one sitting, and commit each step on its own. A monorepo for two sites is not about scale. It is about having one place where a fix lands once.