BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. 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…

6th July 2026·Updated on:17th July 2026··
Next.js
Private Interactive Proposal Portal: How I Built It

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.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The four constraints that decided the architecture
  • Architecture overview
  • The sync pipeline: canonical Markdown to `.generated/`
  • Directive parity: one Markdown grammar, two renderers
  • Audience editions: one build, three views
  • Grounded chat without a vector database
  • Feedback without touching the source
  • Dual auth: a password gate for people, OAuth for agents
  • MCP tools: the proposal as a readable resource
  • Deploy and reuse
  • FAQ
  • Wrapping up
On this page:
  • The four constraints that decided the architecture
  • Architecture overview
  • The sync pipeline: canonical Markdown to `.generated/`
  • Directive parity: one Markdown grammar, two renderers
  • Audience editions: one build, three views
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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.

Architecture overview

text
proposals/<id>/input/discovery/     canonical Markdown (never edited by the portal)
  chapters/NN-*.md
  TOC.md
        │
        ▼  npm run proposal:sync
.generated/                         gitignored, deterministic
  docs/*.mdx + meta.json            Fumadocs pages
  chat/*.md                         clean semantic markdown
  manifest.json
  chunks.json                       H2/H3 retrieval units + audience + anchors
        │
        ▼
Next.js App Router
  /                                 overview from manifest
  /blueprint/<slug>                 chapter pages
  /api/search | /api/chat | /api/sources | /api/feedback
  /api/mcp + OAuth discovery        agent access

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:

  1. Discovers chapters/NN-*.md, sorts by numeric prefix, and rejects duplicates.
  2. Reads TOC frontmatter and the Master Index for chapter status and UPDATED dates.
  3. Runs a remark pipeline that mirrors the PDF parser — parse, directive handling, GFM, orphan-fence cleanup, YAML directive bodies.
  4. Transforms directives into MDX components, so :::bluf becomes <Bluf> and :::scope-table becomes <ScopeTable data=...>.
  5. 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.
  6. 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:

  1. Each directive gets a name and an optional Zod schema for its YAML body, defined in lib/markdown/directives.ts.
  2. lib/markdown/transform.ts turns that directive into MDX component markup.
  3. lib/markdown/semantic.ts converts the same directive into clean semantic markdown for chat and /api/sources.
  4. 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:

ToolPurpose
list_proposal_chaptersProposal metadata plus the ordered TOC — titles, slugs, chapter numbers
get_proposal_chapterFull 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.

Thanks, Matija


Built and running on Next.js 16, Fumadocs, the AI SDK, and Better Auth. Tested against a live 41-chapter client discovery blueprint. Related reading: Stop sending the proposal as a PDF — ship an interactive blueprint. Last updated July 2026.