This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
The work portfolio used to be a folder in this repo. Now it lives in its own repository with its own build, tests and deploy, and this site loads it at runtime. I picked it because it is the part of the site that most behaves like another team's product: one public route, no auth, content that was already rendered in the browser, and only four places where it reached into the rest of the app. Those four places turned out to be the whole job.
Everything that crosses between the two apps lives in @paul-portfolio/work-portfolio-contract, and both repos type-check against it. The remote exposes exactly one thing:
export type RemoteModule = {
contractVersion: number;
version: string;
mount(el: HTMLElement, ctx: HostContext): MountHandle;
};That is deliberately not "export a React component". This site hands the remote an element and a context object, and never renders the remote's tree itself. The remote could move off React, or an Angular remote could sit next to it, and nothing on this side would change. contractVersion is the handshake: a remote built against a different major gets a fallback card instead of a mount, so a breaking change has to ship in steps rather than all at once.
Two copies of React on one page is the classic way this goes wrong, so the host lends its own as a shared singleton. Before writing any of the real thing, I built a throwaway hello-world remote to check that would work with Next 16, and the first thing it showed was the version string the remote would be negotiating with:
19.3.0-canary-cbb046ab-20260731
Next vendors a canary build of React, and a plain ^19 range rejects prereleases. The remote accepts ^19.0.0-0 instead. With that, the spike rendered on the host's React in both next dev and a production build, and the only requests to the remote's origin were the manifest, its entry and the exposed chunk. No React of its own. If that spike had failed, the fallback was Next's multi-zones, which would have been a different design entirely.
The host side uses only @module-federation/runtime. Turbopack has no Module Federation plugin for the App Router, and it turns out the runtime doesn't need one: it fetches the manifest, loads the entry and the chunk, and hands back the module.
Deep links like ?feature=chart-library used to be read and written by the portfolio itself. Two apps writing to one address bar is how you get a back button that does nothing, so the remote now gets the starting slug in and reports every change out through onFeatureChange, and only this side touches history. A test in the remote fails if it ever calls replaceState.
The same goes for data. The referral demo creates real links against my API, but the remote never learns the API's address. It calls services.referrals, and the host wires that to the client it already had. The contract spells out one detail that matters: an API that says no rejects with a readable Error, and an API that can't be reached rejects with the TypeError fetch throws. That is how the demo tells "that slug is taken" apart from "offline, here is a local preview".
I had a guard for the obvious leak: the build fails if the remote's stylesheet touches html, body, :root or a bare element. It passed. Then I mounted the remote in this site for real, and the release chip in the page header disappeared.
The chip is hidden sm:inline-flex. The remote's stylesheet arrives after this site's, into the same utilities cascade layer, with its own .hidden at the same specificity. Later wins. A class-scoped rule is still global if someone else uses the same class name, and in two Tailwind apps everybody does.
The fix is a small PostCSS step after Tailwind that confines every selector to the remote's own subtree:
.hidden{display:none}
becomes
.hidden:where(.work-portfolio-mfe, .work-portfolio-mfe *){display:none}:where() adds no specificity, so inside the remote the cascade behaves exactly as before, and outside it those rules match nothing. The one wrinkle is portals: a modal renders into body, outside the mount root, so the remote's modal wrapper carries the scope class too. The build now fails if any rule it ships to the host is unscoped.
The portfolio's accessibility test was meant to scan a page with a demo open. It set ?feature= in the URL and rendered, but the old component applied deep links a tick later and the demo itself loaded lazily after that, so axe finished scanning before any demo existed. Once the slug became a prop and the test waited for the demo, axe found something that had been there all along:
Elements must only use permitted ARIA attributes (aria-prohibited-attr) aria-label attribute cannot be used on a div with no valid role attribute. <div class="min-h-40 flex-1" aria-label="Signups per minute chart">
A role="img" fixed it. The lesson is the uncomfortable one: a green test tells you it ran, not what it looked at.
The remote ships on its own schedule, so this side treats everything about it as something that can go wrong. The load can fail, it can hang, or a new release can speak a contract major this build doesn't know. RemoteMount turns each of those into a fallback card with Retry, after at most eight seconds, and the header and everything around it keep working. An end-to-end test aborts every request to the remote's origin and checks exactly that, including that nothing was thrown on the page.
Nothing switched over when this merged. A work-portfolio-remote flag decides per visitor, sticky to their bucket, and it starts at zero. It works like the flag that gates the TCG Pocket page with one deliberate difference: that one fails open, because a config gap should never hide a feature that works. This one fails closed, because for a migration a missing flag should mean the page everyone already has. Rolling back is dialling it down, with no deploy on either side.
The cost is that /work-portfolio renders per request while the flag is deciding. Once the remote is at 100% and the in-repo copy is deleted, it goes back to being a static shell.
The new repo starts with every commit that ever touched the portfolio folder, carried over with git filter-repo, so blame still goes back to the first demo. The trap was in the commit messages: a bare #123 linkifies to whatever PR 123 is in the repo it's read in, which in a new repo would eventually be something unrelated. The import rewrote all of them:
regex:(^|[^\w/])#(\d+)==>\1gpbsumido/paul-explore#\2