Skip to main content

Tenant UI source

Your portal UI lives in your tenant config repository, one directory per portal. The platform fetches these files from GitLab, bundles them with esbuild at request time, and serves the result as a single-page application. You own the UI code; the platform owns the build and hosting pipeline — the same ownership model as Edge Journeys' tenant UI source.

File structure

One portal.yaml declares all of a tenant's portals; each portal has its own src/ directory. Meridian's guest portal and agent workspace:

tenants/meridian/portal/
portal.yaml # All portals (documented in the portal.yaml reference)
guest/
src/
main.jsx # Required entry point
App.jsx # Root component: sign-in, trip views
api.js # API client for /api/portal/v1/*
styles.css # Tenant styles (imported from main.jsx)
agent/
src/
main.jsx
...

Each portal is served at /{tenantId}/{portalId}/meridian/guest and /meridian/agent — bundled independently from its own source. Only portals declared in portal.yaml are servable: config presence is the gate, and undeclared paths return 404.

Entry point

The bundler looks for main.jsx (or main.tsx) in the portal's src/ directory. This file must exist — the build fails without it. A minimal entry point mounts your root component:

import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(<App />);

Module system and externals

RuleDetail
Module formatESM only — import / export
ReactReact 18, externalized via a CDN import map — do not bundle React
CSSImport from your entry file; esbuild extracts it and serves it as styles.css
Node built-insForbidden — fs, path, crypto, etc. are not available (this runs in the browser)
SecretsNever in UI source — credentials stay server-side; the UI calls same-origin /api/portal/v1/* endpoints

The platform provides react, react-dom, react-dom/client, and react/jsx-runtime through an import map in the HTML shell. Everything else you import is bundled by esbuild.

Language rules

AllowedNot allowed
.jsx, .js, .css, .json.ts, .tsx (in customer repos)
Browser APIs (fetch, localStorage, DOM)Node built-ins (fs, path, crypto)
ESM import / exportCommonJS require / module.exports
Relative imports (./App.jsx)Absolute filesystem paths

TypeScript entry points are supported in platform test fixtures, but customer tenant repos use JSX only, keeping the build simple and predictable.

API calls

All API calls use relative paths to the same origin. The public config endpoint needs no session (the sign-in screen renders from it); everything else sends the portal session token:

// Before sign-in: label, feature flags, branding
const config = await fetch(`/api/portal/v1/${tenantId}/${portalId}/config`);

// After sign-in: data calls carry the session token
const trips = await fetch(`/api/portal/v1/${tenantId}/${portalId}/trips`, {
headers: { Authorization: `Bearer ${sessionToken}` },
});

See the API reference for the full endpoint surface and Authentication for the magic-link sign-in flow your UI implements.

Bundle lifecycle

  1. Fetch: the platform retrieves tenants/{tenant}/portal/{portal}/src/** from GitLab (or reads a local checkout in development).
  2. Bundle: esbuild compiles the entry point as ESM targeting modern browsers, externalizing React, extracting CSS, and minifying in production.
  3. Shell: the bundle is wrapped in an HTML shell carrying the React import map and links to /{tenantId}/{portalId}/bundle.js and styles.css.
  4. Serve: responses carry an ETag and Cache-Control: public, max-age=60, stale-while-revalidate=300; built bundles are cached in memory for 60 seconds.
  5. Invalidate: pushes to the configured branch trigger a GitLab webhook that invalidates exactly the portals whose tenants/{tenant}/portal/{portal}/ paths changed, and rebuilds them asynchronously.

The iterate-via-MR loop

Portal UI changes ship like any config change: branch, merge request, merge. On merge, the webhook invalidates the affected portal's bundle and a rebuild starts immediately; even without the webhook, the bundle cache expires within 60 seconds. In practice a merged UI change is live in under a minute — there is no application deployment.

Failure behaviour

  • Build failure (for example a syntax error in tenant source): the platform keeps serving the last good bundle until a fixed build replaces it, logging the failure. A portal with no previous good build serves a friendly "being set up" page with a 503.
  • GitLab unreachable: the last good bundle is served where one exists; otherwise the setup page.

Multiple portals, one tenant

The guest portal, agent workspace, and any operations surface are separate portals[] entries in one portal.yaml, each with its own src/, its own feature flags, its own provider setting, and its own audience type. They share nothing at the UI layer — an agent workspace can be redesigned without touching the guest portal — while the platform enforces the audience partition server-side (a guest traveler's email never resolves an identity on an agent portal, and sessions are pinned to their portal).

What each portal's UI actually contains is yours to decide: readiness dashboards, chase workflows, or any workflow your operation needs are built in your portal's src/ against the portal API — they are tenant UI, not platform features.