Skip to content

Architecture

Every request to a portal site flows through a single Astro middleware that decides, based on auth state, whether the response is a server-rendered viewer page with a small set of selectively hydrated islands, or a fully hydrated editor shell.

HTTP request
middleware.ts (packages/core/src/middleware.ts)
├─ /api/media/* ──► pass through (public; session resolved but not required)
├─ /edit/* or /api/* ──► authenticated? ──► editor path
│ │ no
│ └──► redirect /edit/login
├─ public viewer route ──► authenticated session? ──► editor viewing viewer route
│ │ no
│ └──► audience cookie valid? ──► viewer path
│ │ no
│ └──► redirect /login
└─ public routes (login, callbacks) ──► pass through

Viewers get server-rendered HTML by default. React section components are rendered server-side in Astro’s SSR pass — non-interactive sections ship no runtime at all, just plain HTML and CSS. The principle is minimal, intentional viewer JS — no editor code on viewer paths, and no hydration for content that doesn’t need it — not literally zero JavaScript. A deliberate, small set of islands do hydrate on the viewer path:

  • Navigationclient:load (scroll tracking, active-section highlighting, mobile slide-out)
  • Interactive sections — hydrated individually via SectionIsland with client:visible, gated by a section type’s interactive opt-in
  • ChatWidgetclient:load
  • Login forms
  • The full-page document-mode viewer at /present/[...slug] — a client:only island (the one sanctioned exception to server-rendered viewer sections; pagination depends on client-side text measurement of the real DOM layout)

Everything else — non-interactive sections, layout chrome outside those islands — renders to static HTML with no client:* directive and no React runtime shipped for it.

Editors reach /edit, which renders packages/core/src/pages/edit/index.astro. That page mounts the editor shell with client:load:

<EditorShell
headSha={headSha}
draftHeadSha={draftHeadSha}
siteId={siteId}
audiences={audiences}
capabilities={capabilities}
currentUser={currentUser}
client:load
/>

client:load tells Astro to hydrate the component immediately on page load. The result is a fully interactive React application running in the browser — TipTap inline editing, drag-to-reorder, media library, save-to-GitHub — all driven by the same section component tree used for viewer rendering, but wrapped in editor controls.

packages/core/src/middleware.ts runs on every request. The key branching logic:

  • /edit or /edit/* or /api/* — requires a valid session. Missing session redirects to /edit/login (UI) or returns 401 (API). Certain /api/auth/* management routes additionally require the owner role.
  • /api/media/* — publicly reachable (URLs are opaque content hashes, not guessable). Editor sessions receive draft-branch media; viewers receive published media.
  • Viewer routes — if no editor session, checks for a signed audience cookie issued by /api/auth/verify-audience. Invalid or absent cookie redirects to /login.
  • Public routes (/login, /edit/login, auth callbacks) — pass through with no auth check.

The locals.isEditor boolean set by middleware is the signal components use to decide whether to render editor chrome.

The two paths above are how a site is served. Content can also be written from outside the site entirely: the platform’s MCP connector (mcp.drawn.guide) commits to the same saved draft branch in the site’s GitHub repo that the in-browser editor uses, under the same validation and optimistic-concurrency contract. The site itself sees nothing unusual — drafts written from chat show up in /edit, and publishing promotes saved to main and triggers the normal Netlify rebuild.