Case study · my own platform
Aissam Portfolio
The site you are reading and what runs it: a public site in three languages, its back office, its API and an MCP endpoint for AI clients.
- Role
- Owner and sole engineer, from the API to the pages
- Year
- 2026
The challenge
Three apps that share the same types, one person to keep them in step, and an AI client that can do what the back office does.
In numbers
- 103MCP tools, none written by hand: 102 back-office routes read from the route registry, plus whoami.
- 193API routes across 13 modules, each named by an RAI: its role, its module, its action.
- 9Workspaces in one repository: three apps, four packages and two tooling packages.
- 333Commits, from the first on 5 September 2026 to the last on 16 October.
One repository
A change to the contract reaches all three apps
The API, the back office and the site share one Bun and Turborepo workspace because the shapes they exchange had already drifted once. Scope matching existed twice: in the API’s framework, and copied by hand into the back office. The two agreed, and nothing made them.
Three packages now carry what crosses the wire. @app/contracts holds the type of every payload, @app/scopes the permission matcher, and @app/content the Markdown block schemas and the document validator. @app/contracts alone is imported by 90 source files in the API, 88 in the back office and 51 in the site.
They are bundled into the browser and run under Bun, so none of them may import Hono, Node built-ins or React. A change in one of them is a change to the wire contract, checked in the API, the back office and the site together.
Dependencies
Who depends on what
Arrows point from a package to what it imports, as each package.json declares it. contracts and scopes import nothing at all, content adds zod and nothing else, and core runs on Bun only, for the API alone. The two tooling packages depend on none of them.
The back office
Everything the site shows is edited here
Notes, project documents, case studies, the home page, the career map, the site’s strings, media and tags are edited in one Vite and React 19 app. English is the source; French and Arabic are written beside it.
The Markdown editor checks a document with the same validator the API runs before it saves, and its preview is the site itself, in a frame that follows the editor’s scroll.
Screens are gated by the scope their routes need, and the back office checks it with the same matcher the API uses. One account holds the sysadmin role.
A note’s path
A note, from the editor to the page
Step 1 of 5
Write
One Markdown body per language, in the back office. English is required; French and Arabic sit beside it and can come later.
Step 2 of 5
Check
Validate runs validateDocument from @app/content in the editor: flow, replay and faq fences must be JSON that fits their spec, and an unknown demo is flagged. The API’s errors land in the same panel.
Step 3 of 5
Save
The API runs the same validator before it writes and answers the first error with a 422 and its line. A revision of the note as it was is kept; word count and reading time come from the validator.
Step 4 of 5
Publish
A signed preview link opens the draft on the site for 15 minutes. Publishing keeps a revision, clears the notes and taxonomy caches, and pings IndexNow for each language when a key is set.
Step 5 of 5
Render
The site reads the note from the API and runs it through one pipeline on the server: GFM, callouts, embeds, Shiki for code, and the flow, replay, demo and faq fences turned into components. Mermaid draws in the browser.
Architecture
Three apps, one API, one worker
The site reads guest routes, the back office writes through sysadmin routes, and an AI client reaches those same routes through the MCP endpoint. Analytics jobs leave the API for a worker process of their own.
From route to tool
How a back-office route becomes an MCP tool
Step 1 of 5
Route
Declared once: method, path, RAI, roles, scopes, a description and a zod schema. The description is what a model reads to choose the tool.
Step 2 of 5
Registry
At boot, every module’s routes are flattened into one registry. The same declarations feed the permission cache, the dev tools and a generated Postman collection.
Step 3 of 5
Projection
Each sysadmin: route becomes a tool named after its RAI without the role: sysadmin:cases:update is cases_update. Left out: the users module, the media cleanup and the CSV exports.
Step 4 of 5
Sign-in
An AI client signs in by OAuth, with PKCE and dynamic client registration, and the owner approves it on a back-office screen. A pasted mcp_pat_ token works on this endpoint only.
Step 5 of 5
Dispatch
A call becomes app.fetch on the real route, with a JWT valid for one round trip: the same auth, scope check, rate limit and validation as the browser. A create records created_via: mcp.
Replay
One MCP call, replayed
A real cases_update, followed through the code. The durations are medians of 50 calls, measured in process on a development machine. Switch to the failure to watch a schema refusal come back as something the model can fix.
Replay
The public site
One scroll story, in three languages
The home page is one story told by the scroll: a photograph that closes into a card, the road on a map, a sentence that lights up, a ring of case studies in CSS 3D, then the work, a year of contributions, the notes and a way to get in touch.
A case study is a record, not a page: an ordered list of scenes from a catalogue of eleven, typed in @app/contracts, validated by the API and drawn by one component per type. This page is one of them.
English, French and Arabic share every route, and the Arabic pages are right to left from the first paint. Visits are counted by the site’s own analytics, which honours Do Not Track and Global Privacy Control and reaches the API through a same-origin proxy. There is no third-party tracker.
Analytics
The site counts its own visits
Nothing is collected until a visitor accepts the consent banner, and Do Not Track, Global Privacy Control or the site’s own opt-out each keep the tracker silent. Identity is a random hint in sessionStorage, keyed with a server salt; the IP address is used once, for a country, and never stored.
The site sends page views, engagement heartbeats and named events such as a CV download or a finished demo, in batches of at most 20 through the same-origin /a/collect proxy. The API writes them insert-only, so a retried batch changes nothing, and marks bots, previews and non-production hosts so the reports leave them out.
Sessions and daily rollups are projections rebuilt by BullMQ jobs in the worker process: a scanner runs every minute, and a rollup recomputes a whole day and is refused if a newer one has already landed. The back office reads them, with goals, funnels and journeys. Raw events are kept 90 days, rollups 395.
Built with
- Bun
- Hono
- MongoDB
- Mongoose
- Redis
- BullMQ
- Zod
- MCP
- Next.js 16
- React 19
- next-intl
- Vite
- TanStack Router
- Tailwind CSS
- Turborepo
- PM2
Where it stands
Live at aissamirhir.com
One PM2 config declares the four processes, the API, the analytics worker, the back office and the site, for production and for staging. Still to come: a contact inbox in the back office; today the form forwards to an outside endpoint or opens the visitor’s mail app.
Talk about yours- Page routes in each language, from the home page to this case study.
- 13
- Back-office screens behind one sign-in, eleven of them for analytics.
- 34