Get concise advice on choosing the right CMS, understanding migration costs, and avoiding expensive implementation mistakes before they become roadmap problems.
This is the technical companion to Stop sending the proposal as a PDF — ship an interactive blueprint. That post makes the argument for treating a long proposal like a private product doc: navigable, audience-aware, searchable, chattable, and open to feedback, with the PDF reduced to an export format. This post is the build log for how that portal actually works.
The stack in one line: Next.js App Router, Fumadocs for the docs UI, a sync pipeline that regenerates a cache from canonical Markdown, grounded chat on the AI SDK using lexical retrieval instead of a vector database, and dual auth — a shared password for human stakeholders and Better Auth OAuth 2.1 for MCP clients.
The first deployment serves a 41-chapter discovery blueprint for a client engagement. Nothing client-specific is hard-coded into the app itself. A second proposal needs a new PROPOSAL_ID, a new content directory, and a fresh set of secrets.
I recently had to take this from "good idea in a blog post" to something a stakeholder could actually log into, search, ask questions of, and leave feedback on, while an AI agent could read it through MCP. Below is the architecture, the reasoning behind each constraint, and the parts that took more than one attempt to get right.
The four constraints that decided the architecture
Four requirements shaped every decision in this build.
The proposal chapters already live in the repo, feeding a Typst PDF pipeline. I set a rule early: the portal reads from that existing Markdown and never writes back to it. A second content-editing surface on top of the same files would have created two sources of truth to keep in sync by hand.
Stakeholders authenticate before they see anything. Every page carries noindex, nofollow, and proposal text only reaches the browser after a session check passes.
The app needed to serve more than one client without a rebuild. Swapping PROPOSAL_ID and PROPOSAL_CONTENT_DIR should be enough to stand up a second portal for a second proposal.
And the custom directive blocks used in the Markdown — :::bluf, :::edition{audience=...}, and a handful of others — needed to render the same meaning on the web and in the PDF build.
Put together, these four constraints point to one decision: build a sync step that transforms canonical Markdown into a generated cache, and leave the authoring surface exactly where it already lives.
Humans reach a shared-password gate, then browse the Fumadocs UI. Agents such as ChatGPT discover the OAuth metadata, walk through consent, and call MCP tools with a bearer token. The living reference for this app is apps/proposal-portal/README.md; this article covers the design reasoning behind it.
The sync pipeline: canonical Markdown to .generated/
proposal:sync is the piece doing the real work. On each run, it:
Discovers chapters/NN-*.md, sorts by numeric prefix, and rejects duplicates.
Reads TOC frontmatter and the Master Index for chapter status and UPDATED dates.
Runs a remark pipeline that mirrors the PDF parser — parse, directive handling, GFM, orphan-fence cleanup, YAML directive bodies.
Transforms directives into MDX components, so :::bluf becomes <Bluf> and :::scope-table becomes <ScopeTable data=...>.
Splits each chapter into H2/H3 chunks tagged with audience, heading path, and a stable anchor generated with github-slugger, matching the ids Fumadocs renders on the page.
Writes the docs MDX, the chat markdown, manifest.json, and chunks.json.
An unknown directive still renders, through a visible fallback component, and produces a sync warning rather than dropping content silently. A separate proposal:validate step fails the build on real content-loss risks: invalid YAML bodies, unresolved TOC references, duplicate slugs or anchors, missing titles.
Dev mode watches the source files and re-syncs on change. Production builds run sync and validate before next build.
Authors keep editing the same files the PDF builder already reads. The portal sits on top of that output as a presentation layer, regenerated from the source on every sync.
Directive parity: one Markdown grammar, two renderers
Keeping the web and the PDF in agreement comes down to a small, explicit pipeline:
Each directive gets a name and an optional Zod schema for its YAML body, defined in lib/markdown/directives.ts.
lib/markdown/transform.ts turns that directive into MDX component markup.
lib/markdown/semantic.ts converts the same directive into clean semantic markdown for chat and /api/sources.
A matching React component lives under components/directives/, registered in components/mdx.tsx.
Theming runs through one palette: shadcn CSS variables defined in app/global.css, aliased into Fumadocs tokens so the docs chrome and the custom directive components share the same visual language.
Audience editions: one build, three views
Chapters wrap audience-specific content like this:
markdown
:::edition{audience="decision"}
:::bluf
Build in parallel, cut over when validated.
:::
:::
A single build produces all three views. The executive view pulls decision, both, and untagged content. The reference view pulls reference, both, and untagged content. The full view renders everything.
Edition boundaries compile down to client-side <Edition> components. The sidebar selector stores portal_audience in a cookie, the server uses that cookie (or a PROPOSAL_DEFAULT_AUDIENCE fallback) for the first paint, and switching views afterward happens instantly on the client.
Chat retrieval filters context by the same audience rules a stakeholder has selected. Docs search currently indexes the full chapter text regardless of audience — a tradeoff I've documented in the README rather than solved, since narrowing search to the selected edition is on the list for a later pass.
This mirrors the mental model behind the multi-edition PDF builds, running from a single chapter tree instead of three.
Grounded chat without a vector database
Every chat answer gets rebuilt from .generated at request time, so the model always works from the current content rather than from anything it might recall from earlier in the conversation.
Context assembly branches on scope. Asking about one chapter includes that chapter whole, in document order, when the audience-filtered text fits roughly 75% of the context budget; past that, it falls back to the intro plus the top H2/H3 sections, filled out with the best-matching chunk from related chapters. Asking about the full blueprint sends a compact chapter catalog plus the top-ranked chunks from lexical retrieval.
Retrieval scoring runs on term matches, heading matches, title matches, phrase matches, ownership-language cues, and related-chapter signals — no embeddings, no vector store. Ranking stays stable and easy to debug when an answer looks off.
Citations are treated as a product feature rather than a footnote. Every context block carries a stable id such as [§27:editable-and-locked-boundary], the system prompt requires those ids on any material conclusion, and the response stream includes a data-sources part that the client resolves into citation chips. The model never invents a URL, because it never produces one directly.
The system prompt also enforces a distinction between confirmed facts, recommendations, assumptions, drafts, and open decisions, and it's instructed to say plainly when the blueprint has no answer to a question.
Conversations live in localStorage, support multiple sessions, and export to Markdown. Long threads compact deterministically before each send: recent messages stay verbatim, and older ones collapse into a summary that preserves decisions and the citation ids they referenced. Completed answers can request on-demand text-to-speech, with citation tokens and Markdown stripped before the audio is generated and cached in the browser.
The implementation surface lives in app/api/chat/route.ts, lib/chat/*, lib/search/retrieval.ts, and the AI SDK's streamText.
Feedback without touching the source
Stakeholder review runs through its own path rather than an in-document edit.
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, and block targets come from remark-block-id. A reviewer's name and email are optional and can be remembered in localStorage for form prefill on a return visit.
Canonical Markdown stays pristine through all of this. Review threads live in the n8n-backed table rather than in the portal itself, which is a simplicity tradeoff I made on purpose for a first version.
Dual auth: a password gate for people, OAuth for agents
Reviewers get a lightweight gate built from a scrypt password hash stored in PORTAL_PASSWORD_HASH (the raw password is never stored), an HMAC-signed HTTP-only session cookie, and proxy.ts — the Next.js 16 proxy convention — acting as the first-line check. Sensitive routes and the portal layout re-verify the session on top of that.
MCP clients need a different flow entirely: dynamic client registration, PKCE, a consent screen, JWTs, and scopes like proposal.read and offline_access. Better Auth runs the authorization server, and the MCP route verifies each bearer token's issuer, audience, and scope before any tool executes.
The integration detail that cost me the most time: the OAuth discovery and MCP endpoints have to be exempt from the shared-password proxy. The middleware route matcher is the usual cause when this breaks — a login-page redirect on the discovery GET request cascades into failures in a connector's setup flow that look, from the outside, like MCP itself being unreliable.
A longer write-up of the OIDC shim, JWKS verification, and ChatGPT-specific edge cases lives in docs/mcp-nextjs-integration.md. One correction worth stating here directly: the production route runs on Streamable HTTP (WebStandardStreamableHTTPServerTransport), which replaced an earlier SSE-based draft.
MCP tools: the proposal as a readable resource
The MCP server exposes two read-only tools:
Tool
Purpose
list_proposal_chapters
Proposal metadata plus the ordered TOC — titles, slugs, chapter numbers
get_proposal_chapter
Full clean Markdown for one chapter, by slug or number
Both tools pull from the synced manifest and chat markdown through a shared source service, the same cleaned text a human reader gets from the page's Copy Markdown button.
Discovery runs through protected resource metadata at /.well-known/oauth-protected-resource, authorization server metadata served by Better Auth, and a consent UI at /oauth/consent. Setting MCP_ENABLED=false returns a 404 on the entire surface when it needs to be turned off.
Humans browse, search, chat, and leave feedback through the password portal. MCP gives agents a second channel into the same content, running alongside that portal rather than replacing it.
Deploy and reuse
This ships as a full Next.js deployment, with live server routes for chat, auth, and MCP — not something you can export statically.
A few Vercel specifics that cost me a debugging session the first time:
Root directory is apps/proposal-portal.
"Include source files outside of the Root Directory" needs to be on, so the build can read proposals/... from the repo root.
CLI deploys need to run from the repository root, or the proposal sources never upload.
Node 22+ works fine.
Standing up a second proposal takes a new Vercel project on the same root directory, a different PROPOSAL_ID and PROPOSAL_CONTENT_DIR, a fresh password hash and auth secret, and optionally its own OpenAI key, model choice, expected chapter count, and PDF drive links. Branding — title, client name, dates, statuses — comes entirely from TOC frontmatter, so none of it lives in application code.
FAQ
Why not use a vector database for chat retrieval?
The chapter count and chunk count for a single proposal are small enough that lexical scoring on terms, headings, titles, phrases, and related-chapter signals gives ranking that's fast, deterministic, and easy to debug when an answer looks wrong. A vector store becomes worth the added infrastructure at a scale this project hasn't reached yet.
How do you keep the web portal and the PDF export in agreement?
Both read from the same canonical Markdown and the same directive definitions. The web renderer converts a directive into an MDX component; the PDF pipeline converts the same directive into its Typst equivalent. Neither one edits the source.
What breaks if the password proxy isn't configured to skip OAuth and MCP routes?
The discovery GET request from an MCP client gets redirected to the login page, and the connector setup in a client like ChatGPT fails in a way that looks like a protocol or reliability problem. The proxy matcher is the first place to check.
Can this app be reused for a client project that isn't this one?
Yes — that was a requirement from the start. A new Vercel project, new environment variables, and a fresh password hash are enough; nothing client-specific lives in the application code itself.
Why a shared password instead of individual stakeholder accounts?
For a short-lived proposal review with a handful of stakeholders, a shared password gets everyone in without the overhead of an account system. It's a tradeoff that fits this use case and wouldn't fit a longer-lived multi-tenant product.
Wrapping up
The core problem here was giving a long, structured proposal the same interactivity as a product doc, while keeping one canonical source that also feeds a PDF pipeline and staying open to an AI agent reading it through MCP. The sync-and-validate step, the audience-edition tags, lexical retrieval with citation ids, and a proxy that knows which routes to leave open for OAuth discovery are the pieces that made that possible.
If you're building something similar, the reusable part isn't the proposal-specific content. It's the pattern: a generated cache sitting between your canonical source and your presentation layer, one Markdown grammar serving more than one output format, and an auth setup that treats humans and agents as genuinely different audiences instead of forcing them through the same gate.
Let me know in the comments if you have questions, and subscribe for more practical development guides.