---
title: "Private Interactive Proposal Portal: How I Built It"
slug: "private-interactive-proposal-portal"
published: "2026-07-06"
updated: "2026-07-17"
validated: "2026-07-17"
categories:
  - "Next.js"
tags:
  - "interactive proposal portal"
  - "private proposal portal"
  - "Next.js App Router"
  - "Fumadocs"
  - "canonical Markdown sync"
  - "grounded chat"
  - "MDX directives"
  - "dual auth OAuth"
  - "proposal sync pipeline"
  - "no vector DB"
  - "remark pipeline"
  - "MCP tools"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "next.js app router"
  - "fumadocs"
  - "typst"
  - "mdx"
  - "remark"
status: "stable"
llm-purpose: "Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…"
llm-prereqs:
  - "Access to Next.js App Router"
  - "Access to Fumadocs"
  - "Access to Typst"
  - "Access to MDX"
  - "Access to remark"
llm-outputs:
  - "Completed outcome: Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…"
---

**Summary Triples**
- (proposal portal, built_with, Next.js App Router + Fumadocs + canonical Markdown→MDX sync + grounded chat (AI SDK, no vector DB) + dual auth)
- (canonical Markdown, location_pattern, proposals/<id>/input/discovery/ (chapters/*.md, TOC.md) — treated as single source of truth)
- (sync pipeline, produces, .generated/docs/*.mdx and .generated/meta.json (gitignored, deterministic) for Fumadocs)
- (MDX directives and edition tags, maintained_for_parity, sync converts custom :::directive and :::edition{audience=…} so web and Typst PDF behave identically)
- (grounded chat, implemented_as, AI SDK streaming with source-grounded text files (no vector DB) served from private endpoints)
- (privacy, enforced_by, authentication, noindex pages, and avoiding public machine-readable endpoints)
- (authentication, strategy, dual auth: shared password for human stakeholders + Better Auth OAuth (OAuth2.1) for MCP)
- (reusability, achieved_by, configurable PROPOSAL_ID / PROPOSAL_CONTENT_DIR and environment variables to deploy new proposals)
- (deployment_example, serves, 41-chapter discovery blueprint with no content hard-coded for a specific client)
- (automation, can_be_integrated_with, n8n workflows for sync triggers and pipeline orchestration)

### {GOAL}
Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…

### {PREREQS}
- Access to Next.js App Router
- Access to Fumadocs
- Access to Typst
- Access to MDX
- Access to remark

### {STEPS}
1. Audit canonical Markdown sources
2. Implement the sync pipeline
3. Transform directives to MDX
4. Chunk chapters for retrieval
5. Build Next.js presentation layer
6. Implement dual authentication
7. Add grounded chat and citations
8. Deploy and reuse configuration

<!-- llm:goal="Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…" -->
<!-- llm:prereq="Access to Next.js App Router" -->
<!-- llm:prereq="Access to Fumadocs" -->
<!-- llm:prereq="Access to Typst" -->
<!-- llm:prereq="Access to MDX" -->
<!-- llm:prereq="Access to remark" -->
<!-- llm:output="Completed outcome: Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…" -->

# Private Interactive Proposal Portal: How I Built It
> Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…
Matija Žiberna · 2026-07-06

This is the technical companion to [Stop sending the proposal as a PDF — ship an interactive blueprint](/blog/interactive-proposal-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:

| 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.

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](/blog/interactive-proposal-blueprint). Last updated July 2026.*

## LLM Response Snippet
```json
{
  "goal": "Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…",
  "responses": [
    {
      "question": "What does the article \"Private Interactive Proposal Portal: How I Built It\" cover?",
      "answer": "Interactive proposal portal built with Next.js and Fumadocs — learn the canonical Markdown sync, MDX directives, grounded chat without a vector DB, and…"
    }
  ]
}
```