Private Interactive Proposal Portal: How I Built It
Private Interactive Proposal Portal: How I Built It
Technical companion: Next.js + Fumadocs sync pipeline, canonical Markdown→MDX, grounded chat without a vector DB, and…
·Updated on:··
Get Practical CMS Decision Briefs
Get concise advice on choosing the right CMS, understanding migration costs, and avoiding expensive implementation mistakes before they become roadmap problems.
The product thesis is simple: a long proposal should behave like private product docs — navigable, audience-aware, searchable, askable, annotatable — with PDFs as an export, not the primary artifact.
This post is how that portal is built.
Stack in one line: Next.js App Router + Fumadocs + a sync pipeline from canonical Markdown + grounded chat (AI SDK, no vector DB) + dual auth (shared password for humans, Better Auth OAuth for MCP).
The first deployment serves a 41-chapter discovery blueprint. Nothing client-specific is hard-coded — another proposal is environment variables and a different content directory.
Constraints that shaped the design
One source of truth. Proposal chapters already live in the repo for a Typst PDF pipeline. The portal must not become a CMS fork of those files.
Those constraints push you toward a sync boundary, not a second authoring environment.
Humans hit a shared-password gate, then browse Fumadocs UI. Agents (for example ChatGPT) discover OAuth metadata, consent, and call MCP tools with a Bearer token.
The living ops doc for this app is apps/proposal-portal/README.md. What follows is the design reasoning, not a substitute for that README.
Sync: canonical Markdown → .generated/
proposal:sync is the heart of the system.
It:
Unknown directives never delete content: they render through a visible fallback and produce a sync warning. A separate proposal:validate step fails the build on real content-loss risks (invalid YAML bodies, unresolved TOC references, duplicate slugs/anchors, missing titles).
Dev mode watches and re-syncs. Builds sync + validate before next build.
Why this works: authors keep editing the same files the PDF builder already understands. The portal is a presentation layer over a generated cache.
Directive parity and MDX components
Custom blocks are registered in a small pipeline:
Name + optional Zod schema for YAML bodies (lib/markdown/directives.ts)
Transform to MDX component markup (lib/markdown/transform.ts)
Semantic markdown conversion for chat / /api/sources ()
Theming follows one palette: shadcn CSS variables in app/global.css, aliased into Fumadocs tokens so docs chrome and custom blocks stay visually consistent.
Audience editions as a view, not a fork
Chapters wrap content in:
markdown
:::edition{audience="decision"}
:::bluf
Build in parallel, cut over when validated.
:::
:::
The portal renders all three views from one build:
Executive — decision + both + untagged
Reference — reference + both + untagged
Full — everything
Edition boundaries become client <Edition> components. The sidebar selector stores portal_audience in a cookie; the server uses that cookie (or PROPOSAL_DEFAULT_AUDIENCE) for the first paint; switching is instant client-side.
Chat retrieval filters by the same audience rules. Docs search currently indexes the full chapter text — a documented tradeoff for an authenticated portal.
This is the same mental model as the multi-edition PDF builds, without maintaining three chapter trees.
Grounded chat without a vector database
Proposal knowledge is re-read from .generated on every answer. The model is not allowed to answer from conversation memory alone.
Context assembly
Ask this chapter — if the audience-filtered chapter fits ~75% of the context budget, include it whole in document order; otherwise intro + top H2/H3 sections, plus the best chunk from related chapters while budget remains.
Ask the full blueprint — a compact chapter catalog plus top-ranked chunks from deterministic lexical retrieval.
Retrieval scores term, heading, title, phrase, ownership-language, and related-chapter signals. No embeddings. No vector store. Ranking is stable and debuggable.
Citations as product UX
Every context block carries a stable id like [§27:editable-and-locked-boundary]. The system prompt requires those ids on material conclusions. The stream includes a data-sources part; the client renders citations as chips resolved against that list — the model never invents URLs.
The prompt also forces epistemic honesty: distinguish confirmed facts, recommendations, assumptions, drafts, and open decisions; say when the blueprint has no answer.
Sessions and narration
Conversations live in localStorage (multiple sessions, export as Markdown). Long threads compact deterministically before send: recent messages verbatim, older ones collapsed into a summary that preserves decisions and cited source ids.
Completed answers can request on-demand TTS. Citation tokens and Markdown are stripped before speech; audio is session-cached in the browser only.
Implementation surface: app/api/chat/route.ts, lib/chat/*, lib/search/retrieval.ts, AI SDK streamText.
Feedback without editing the source
Stakeholder review is intentionally not an in-document CMS edit.
Flow:
text
Select passage or page-level feedback
→ authenticated POST /api/feedback
→ n8n webhook (shared secret)
→ Data Table upsert + Brevo notification
Selection highlighting uses the CSS Custom Highlights API. Block targets come from remark-block-id. Identity (name/email) is optional and may be remembered in localStorage for form prefill.
Canonical Markdown stays pristine. There is no in-portal reply thread — review lives in the n8n table. That is a deliberate simplicity tradeoff.
Dual auth: humans vs agents
Humans — shared password
Reviewers get a lightweight gate:
Scrypt password hash in env (PORTAL_PASSWORD_HASH) — raw password never stored
HMAC-signed HTTP-only session cookie
proxy.ts (Next.js 16 proxy convention) as the first-line gate
Sensitive routes and the portal layout re-verify the session
robots.txt disallows everything; pages send noindex, nofollow. Proposal text reaches the browser only after authentication.
Agents — Better Auth OAuth 2.1 + MCP
MCP clients need a different story: dynamic client registration, PKCE, consent, JWTs, scopes (proposal.read, offline_access).
Better Auth owns the authorization server. The MCP route verifies Bearer tokens (issuer, audience, scope) before tools run.
Critical integration detail: OAuth discovery and MCP endpoints must be exempt from the shared-password proxy. If ChatGPT’s discovery GET is redirected to /login, you get a cascade of broken redirects and cached failures that look like “MCP is flaky” when the real bug is middleware.
A longer write-up of OIDC shims, JWKS verification, and ChatGPT-specific traps lives in mcp-nextjs-integration.md / docs/mcp-nextjs-integration.md. One correction worth stating here: the production route uses Streamable HTTP (WebStandardStreamableHTTPServerTransport), not the older SSE sketch some guides still show.