Case study · 04 / 11
Turning one client's intranet into a product
A case study — RMZ Intranet Suite
Summary
| | | |---|---| | **Problem** | A SharePoint Online employee intranet built for one client, needed again for the next — and the next. Rebuilding per client was not sustainable. | | **Constraint** | Rebranding had to require no code change, and the copied solution had to be safe to deploy into the *same tenant* as its parent. | | **Solution** | 25 SPFx web parts plus a global header/footer extension, driven entirely by runtime settings, with a script that regenerates every component identifier. | | **Outcome** | Deployed to its first client; the same codebase generalised into a bilingual, rebrandable product across three repositories. | | **Role** | Solution architecture, all web parts and the extension, the design system, provisioning, productisation, documentation. | | **Stack** | SPFx 1.22.1 · React 17.0.1 · TypeScript 4.7.4 · PnPjs v4 · Fluent UI React v8 · Microsoft Graph · SCSS · Gulp 4 · Node 18–22 |
1. The problem
An employee intranet is the most-requested SharePoint deliverable and the least reusable. Every client wants the same eleven things — news, events, policies, documents, a people directory, department sites, onboarding, vacancies — and every client wants them to look like *their* company. The usual outcome is a fresh build each time, or a copied repository that quietly diverges until the two cannot be maintained together.
The goal here was to build the intranet once and make the *next* deployment a configuration exercise.
2. The constraint that made it hard
Two constraints, and the second is the one that nearly caused an incident.
Everything branded must be a setting. Client name, logo, accent theme, fonts, navigation, quick links, departments, world clock regions, notification recipients — all of it editable from an Admin web part after deployment, none of it requiring a rebuild.
A copy of the repository must be safe to deploy alongside its parent. This is where SPFx bites. Every component carries a GUID, and the App Catalog keys on those GUIDs rather than on the package filename. A copied solution deployed to the same tenant as the one it came from is either rejected as a duplicate or silently replaces it.
This portal was itself copied from an earlier one, kept its GUIDs, and was headed for the same tenant as the solution it came from. The collision was caught before deployment. It would otherwise have taken out a live portal.
3. Architecture
A global header and footer as an Application Customizer, injected into the Top and Bottom placeholders on every page. 25 React web parts in one toolbox group, all styled from a shared token file. A Setup web part that provisions everything the portal needs. A settings service that every component reads from, with a cache.
Every data-backed web part falls back to sample data when its source list is empty or unavailable, so the entire portal renders in the SPFx workbench before a single list exists. That is a small decision with a large effect on how the thing is demoed, reviewed and developed.
Data splits cleanly: PnPjs for lists and libraries, Microsoft Graph for people and calendars. Graph scopes are deliberately held to the three the code actually uses — User.Read, User.Read.All, Calendars.Read — with a note in the README that the list should track the code rather than grow speculatively, because every scope is something an administrator has to approve and justify.
4. The interesting decisions
4.1 The identifier problem, and the script that solves it
Component GUIDs appear in four places that all have to agree, and each one fails differently:
| Where | What breaks if it drifts | |---|---| | `src/**/*.manifest.json` | the component's own identity | | `config/package-solution.json` → `componentIds` | the component is packaged but **never registers** — it simply never appears in the toolbox | | `ProvisioningService.ts` | Setup builds pages by GUID; an unknown one produces a page that comes out empty | | `config/serve.json` | `gulp serve` loads nothing |
A search-and-replace over package-solution.json alone gives you a solution that installs cleanly and then fails to build a single page — the worst kind of failure, because it looks like success.
scripts/new-client.js regenerates every component GUID, the solution id and the feature id; rebuilds `componentIds` from the manifests rather than translating the old list; renames the package and solution; sets the default client name; resets the version; and re-runs a consistency check on its own output. It has a --dry-run. There is also an npm run verify:ids that can be run at any time.
The lesson generalises: when the same identifier lives in four files, the fix is not discipline. It is a script that derives the other three from one source and then checks itself.
4.2 A design system with a grammar, not just tokens
_tokens.scss is the implementation every web part imports, and the README states the rules rather than leaving them to be inferred:
- One red, and red is a signal rather than a surface — primary action, active nav, "action required". *Two red things in a viewport means one is wrong.*
- Segoe UI only. Hierarchy comes from weight (800/700/600/400), never a second family.
- 4px radius on cards, buttons, inputs and tiles; fully round only for pills and avatars.
- Surfaces alternate white → surface → surface-alt. No gradients except the hero scrim.
- Blue and gold are for categorisation and data, never for actions.
One detail shows the level the system was taken to: brand red measures only 3.8:1 against the dark ground, so small red text on dark uses a separate $kc-red-on-dark token. A check-contrast script validates the palette rather than trusting the eye.
Two themes, one arrangement. Both are a dark masthead and hero over light content, closed by a dark footer; they differ only in the tone of that dark ground — a neutral near-black, and one warmed toward the brand red. Applied at runtime through CSS custom properties, so switching needs no rebuild. There is deliberately no all-dark theme: a dark body fights every embedded Microsoft surface — list views, the command bar, Office previews — which stay light regardless.
4.3 A floating masthead that knows when not to float
The header has two shapes. The default is a full-width bar, chosen because it is the shape that holds up inside SharePoint, where the canvas width, the command bar and a logo that needs a white chip all work against a floating pill.
The alternative is a floating translucent pill the page scrolls behind, matched to the 1264px canvas so it lines up with the content beneath. It overlays the hero only on pages that have one: the hero stamps data-kc-hero on the page, and only then does the header give back its height and drop to 42% opacity.
Without that guard, a floating masthead would hide the first 80px of every ordinary page in the portal. The guard is three lines. Finding out it was needed was not.
4.4 Small correctness details worth naming
World Clock. Regions are chosen from a catalogue of 237 zones across 196 countries by human-readable name, not IANA string. Offsets are derived by formatting the same instant in both zones and differencing the readings — so DST is the platform's problem and no time-zone database ships with the solution. Zones the browser does not recognise are filtered out of the picker rather than offered and then silently dropped. The clock ticks every second but re-renders only when the displayed minute changes.
Vacancies. Attachments are native list attachments — no extra library — and are uploaded *after* the item exists, so a rejected file never costs the posting. Expiry filtering (openOnly) happens server-side; a posting with no expiry never expires; a closed posting resolves on the detail page and says so rather than offering a dead Apply button.
Contributor content is sanitised. An allow-list unwraps unknown tags and drops every attribute except a safe href, before anything a contributor wrote reaches a reader.
4.5 Provisioning as a product feature
The Setup web part creates every list and library, registers the header/footer extension, seeds content, builds the pages and provisions department sub-sites — idempotently, so re-running is a repair action rather than a hazard.
This replaced what would normally be a PowerShell runbook handed to the customer. The difference matters commercially: deployment becomes "add the app, click Run Setup" and can be done by a site owner, not a consultant.
4.6 Generalising to bilingual
The later variant adds English/Arabic with full RTL. The design decision worth noting is on the data side: setup provisions matching *AR columns (TitleAR, DescriptionAR, BodyAR, LocationAR, SummaryAR) alongside the English ones on every list, so there is one set of list items, not a parallel Arabic list. Editors fill both languages from the same form; each web part renders the Arabic column when it has been filled and falls back to English otherwise.
A header toggle flips the page to dir="rtl"/lang="ar" and persists per browser, with a configurable default for first-time visitors.
5. Outcome
Deployed to its first client, and generalised across three repositories that track the product line: the client build, an earlier client build, and the bilingual generalisation. Per-site deployment scope (skipFeatureDeployment: false), so the header and footer appear only where the app is explicitly added — a portal at /sites/<site> does not push its navigation to the tenant root.
6. What I'd take from this
Productising is mostly about identifiers and defaults. The visual work — themes, tokens, logo handling — was the easy half. The half that decides whether a second deployment is safe is component identity, and it is invisible until it breaks something live.
Fallback data is a feature, not a dev convenience. Every part rendering with sample data means the portal can be demonstrated, reviewed and styled before any content exists. It shortened every feedback loop on the project.
Write the design rules down as rules. "Two red things in a viewport means one is wrong" is enforceable in review in a way that a hex value in a variables file is not.