Skip to main content
WORK/ONEPORTAL

OnePortal.

A micro-frontend shell that replaced dozens of disjointed SharePoint tools with one unified portal: shared auth, shared design, and a path off SPFx for every team that needed it.

Year2025
RoleTech Lead · Architecture & Build
Statusinternal
URL-
1
Unified shell
N
Federated remotes
SSO
End-to-end
Hrs
Team onboarding

Internal tools don't get to pick their users. They inherit them.

01 / The Problem

Over a decade, the organisation accumulated dozens of internal applications. Every team that needed a web app reached for the same toolkit: SharePoint Online. It was the path of least resistance. Authentication was handled, hosting was handled, and the security team signed off on it by default.

What it couldn't handle was consistency. Every team designed their own interface. Every tool looked different, behaved differently, and navigated differently. A user who lived in four of these tools a day spent most of their cognitive budget translating between them.

The teams building these tools had their own pain. SharePoint Framework (SPFx) was the sanctioned way to customise, and SPFx was hostile to modern frontend practice. The toolchain was heavy, the deployment model was rigid, and onboarding a new engineer to it took weeks they didn't have. Most teams shipped ageing UIs because the cost of building a new one was higher than the cost of tolerating the old one.

The ask was deceptively simple: give teams a way to ship a modern web app inside the organisation's identity perimeter, without picking up SPFx, without reinventing the design system, and without getting stuck if they wanted to change their stack later.

02 / The Approach

OnePortal is a shell. It owns authentication, navigation, search, theming, and notifications. It does not own the apps inside it. Each team's application, a remote in micro-frontend vocabulary, registers with the shell and plugs in at runtime. The shell handles sign-in once; the remotes inherit the session. The shell exposes a shared design system; the remotes import it or ignore it. Teams pick their own framework, their own router, their own data layer.

The first version of the shell was built on React with Module Federation. It worked, but it coupled every remote to the shell's React version. When major React ecosystem churn hit, with new compiler semantics, concurrent features reshaping provider patterns, and Module Federation plugins moving faster than any one React minor, we found ourselves making a platform decision every time any team wanted to upgrade.

The second version, the one running now, changed the foundation. The shell is built with Lit web components on Rspack. Web components compile to browser primitives, so the shell is framework-agnostic to everything inside it. A remote built in React 18, a remote built in React 19, and a remote built in Vue can all sit side-by-side under the same shell without fighting over the reconciler. The shell no longer forces a framework decision; it just offers a contract.

Around the shell sits a small ecosystem: a bridge package that handles host-to-remote communication over a typed event channel, a design tokens package (CSS custom properties, no framework coupling), an SDK types package that gives remotes a typed view of the shell's API, and a scaffolding CLI that stamps out a new remote with the right federation config, the right auth provider, and the right boilerplate in under a minute.

Authentication is the second thing that got simpler. The shell runs MSAL once, acquires tokens once, and broadcasts sign-in state to remotes via the bridge. A new remote gets SSO for free. No MSAL configuration, no redirect flow to handle, no token lifecycle to worry about. The pattern that used to be two hundred lines of boilerplate in every tool is now three lines.

The administration UI is a separate application sitting at the end of this chain, a React + TanStack + Radix app, used to register remotes, manage favourites, and run portal-wide ops. Keeping it separate meant the shell stayed small and the ops team got an interface tuned for their workflow, not bolted onto the shell's sidebar.

03 / The Stack

Shell: Lit + Rspack + MSAL. Lit keeps the shell's output as browser-native custom elements, which is what allows the framework-agnostic promise to actually hold. Rspack handles the Module Federation graph and build performance on a codebase that now spans multiple independent remotes. MSAL authenticates once at the shell layer.

Packages monorepo: bridge (host ↔ remote event channel with typed payloads), SDK types, design tokens (plain CSS variables that work in every framework without a compiler), React-specific adapters for the majority of remotes that use React, and a CLI for scaffolding new remotes.

Shell API. A small service that owns portal-wide state: app registrations, user favourites, notification delivery. It's separate from each remote's own backend, so portal-level concerns don't leak into individual apps' data models.

Admin UI. React 18 with TanStack Router, TanStack Query, TanStack Table, Radix UI, next-themes. This is where the heavier interactive work lives; picking React + Radix here let the ops team move fast on tables, forms, and wizards.

Deployment. Each remote ships to Azure Static Web Apps with its own pipeline. The shell pulls remoteEntry manifests at runtime, so adding a new remote doesn't rebuild the shell. It just registers.

04 / What I'd Do Differently

Don't ship v1 as the platform. The first version tried to be the shell, the component library, the router, the auth wrapper, and the scaffolding tool all at once. It meant every team consuming the platform inherited every one of our opinions. By the time we learned which opinions were right, some of them had calcified into APIs we couldn't easily change. I'd start smaller: ship the shell and the auth bridge first, let teams build remotes on whatever they want, and only graduate shared primitives into a package after two or three teams have independently asked for the same thing.

Pick "platform" seriously. OnePortal didn't have a versioning story for its first nine months. Remotes updated when we pushed a change. It worked until it didn't. A breaking bridge API update caught three teams mid-sprint. Semantic versioning for the packages, a deprecation window for the shell's runtime APIs, and a changelog the consuming teams can subscribe to. That's what a platform owes its users, and we got there later than we should have.

Invest in observability before feature parity. For the first six months, when a remote crashed, we found out because a user complained in Slack. The shell knew something had gone wrong. It just didn't have anywhere to report it. A logging pipe from the shell and a bridge-level error channel that routes remote failures to a dashboard would have saved a full engineer-week of "who broke what" archaeology over the first year.

Rewrite with intent, not with frustration. The Lit rewrite was the right call, but the trigger wasn't strategy. It was cumulative pain. If I had run the same evaluation six months earlier, with a thirty-line prototype and a one-page tradeoff matrix, we would have hit the same conclusion cheaper and without the months of arguing about whether the React version could be saved. Rewrites are sometimes correct; waiting for them to feel inevitable is the expensive way to arrive at that conclusion.

/ STACK
LitRspackModule FederationMSALTypeScriptAzure SWA
NEXT / CASE 01 OF 02

Loanly.

A home-loan calculator for India. EMI, PMAY, tax. One tool, built to replace the eight browser tabs every homebuyer ends up with.