Server-Side Frameworks
OpenNav’s OpenNavServer gives every server-rendered page a Markdown
representation through standard HTTP content negotiation. When a client sends
Accept: text/markdown, the server converts the HTML response to clean Markdown
on-the-fly. Browser requests still receive normal HTML. Both responses include
Vary: Accept so caches keep the two representations separate.
Quick Start
Section titled “Quick Start”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
app.get("/docs/:slug", async (c) => { const htmlResponse = await renderPage(c.req.param("slug")); const result = await opennav.negotiate({ request: c.req.raw, htmlResponse, }); if (result.isErr()) return c.text("Internal error", 500); return result.value;});OpenNavServer works with standard Request / Response objects, so it fits
any WinterCG-compatible runtime: Hono, Astro, Next.js, Cloudflare Workers, Bun,
SvelteKit, and more.
What Happens Per Request
Section titled “What Happens Per Request”| Client Accept header | Response |
|---|---|
Prefers text/markdown | HTML body is converted to Markdown. Content-Type: text/markdown; charset=utf-8. |
Prefers text/html | Original HTML passes through unchanged. A Link: </path>; rel="alternate"; type="text/markdown" header is added so agents can discover the Markdown representation. |
| No matching type (406) | 406 Not Acceptable with Content-Type: text/plain; charset=utf-8. |
All three response paths include Vary: Accept.
Framework Examples
Section titled “Framework Examples”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
app.get("/docs/:slug", async (c) => { const htmlResponse = await renderPage(c.req.param("slug")); const result = await opennav.negotiate({ request: c.req.raw, htmlResponse, }); if (result.isErr()) return c.text("Internal error", 500); return result.value;});Astro (SSR)
Section titled “Astro (SSR)”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
export const GET: APIRoute = async (ctx) => { const htmlResponse = await ctx.render(); const result = await opennav.negotiate({ request: ctx.request, htmlResponse, }); if (result.isErr()) return new Response(null, { status: 500 }); return result.value;};Next.js
Section titled “Next.js”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
export async function GET(req: Request) { const htmlResponse = await fetchPageHtml(req.url); const result = await opennav.negotiate({ request: req, htmlResponse, }); if (result.isErr()) return new Response(null, { status: 500 }); return result.value;}Cloudflare Workers
Section titled “Cloudflare Workers”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url);
if (url.pathname.startsWith("/docs/")) { const htmlResponse = await env.ASSETS.fetch(request); const result = await opennav.negotiate({ request, htmlResponse, }); if (result.isOk()) return result.value; }
return env.ASSETS.fetch(request); },};Cloudflare Pages Functions
Section titled “Cloudflare Pages Functions”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
export async function onRequest(context) { if (context.request.url.includes("/docs/")) { const htmlResponse = await context.next(); const result = await opennav.negotiate({ request: context.request, htmlResponse, }); if (result.isOk()) return result.value; } return context.next();}Cloudflare Workers and Pages: Avoid Wasted CPU
Section titled “Cloudflare Workers and Pages: Avoid Wasted CPU”Cloudflare’s default routing behavior is efficient: if a request matches a
static file on disk, that file is served directly without invoking your Worker
code. Your Worker only runs for requests that don’t match a static asset (or
when you explicitly use run_worker_first).
Recommendation: Keep the default routing when you have static assets.
Deploy your OpenNav-generated Markdown files (*.md, llms.txt,
llms-full.txt) alongside your static HTML. Cloudflare will serve the .md
files directly from disk — zero CPU, zero Worker invocation — when agents
request them by path.
If you instead route every request through OpenNavServer, you pay for Worker
CPU on every agent request to convert HTML back into the same Markdown you
already generated at build time. That is wasted cost.
When to use OpenNavServer on Cloudflare
Section titled “When to use OpenNavServer on Cloudflare”Use OpenNavServer on Cloudflare when:
- Your pages are fully SSR (no static HTML or Markdown exists on disk).
- You have dynamic or personalized content that can’t be pre-generated.
- You want Markdown for paths that don’t exist as static
.mdfiles.
If your Markdown files are already on disk, let Cloudflare serve them directly.
If a page has no .md sibling, Cloudflare’s own Markdown for
Agents
feature can convert HTML to Markdown at the edge with zero application code (Pro
plan and above).
Cloudflare’s Built-In Markdown for Agents
Section titled “Cloudflare’s Built-In Markdown for Agents”Cloudflare offers a native Markdown for
Agents
feature that converts HTML to Markdown at the edge with no application code
required. When enabled for your zone, Cloudflare automatically handles
Accept: text/markdown requests by fetching your origin HTML, converting it to
Markdown, and returning it.
| Approach | Best for |
|---|---|
Static .md files on disk | Pre-generated pages. Zero CPU, zero cost. Deploy OpenNav’s build output. |
| Cloudflare Markdown for Agents | Any origin HTML. Zero application code. Requires Pro or Business plan. |
OpenNavServer in a Worker | Full control over conversion, custom stripping rules, or platforms without Cloudflare’s native feature. |
Static .md first, runtime fallback
Section titled “Static .md first, runtime fallback”If you use run_worker_first (for example, you have API routes alongside docs
pages), Cloudflare’s default routing can’t help — every request invokes your
Worker. But you can still avoid wasted CPU by checking for a static .md file
first:
import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url);
// Parse the Accept header without fetching or converting anything. const decision = opennav.accept(request);
// HTML (or no Accept header) — pass through with Vary + Link. if (decision === "text/html" || decision === null) { const htmlResponse = await env.ASSETS.fetch(request); const result = await opennav.negotiate({ request, htmlResponse }); if (result.isErr()) return new Response("Internal error", { status: 500 }); return result.value; }
// Markdown — try static .md first to avoid conversion cost. // /docs/foo → /docs/foo.md, /docs/foo/ → /docs/foo.md, /docs/foo.html → /docs/foo.md const mdPath = url.pathname .replace(/\/$/, "") .replace(/\.html$/, "") + ".md";
const staticMd = await env.ASSETS.fetch( new Request(new URL(mdPath, request.url), request), );
if (staticMd.ok) { const headers = new Headers(staticMd.headers); headers.set("Vary", "Accept"); headers.set("Content-Type", "text/markdown; charset=utf-8"); return new Response(staticMd.body, { status: staticMd.status, statusText: staticMd.statusText, headers, }); }
// No static .md — convert HTML to Markdown on-the-fly. const htmlResponse = await env.ASSETS.fetch(request); if (!htmlResponse.ok) return htmlResponse;
const result = await opennav.negotiate({ request, htmlResponse }); if (result.isErr()) return new Response("Internal error", { status: 500 }); return result.value; },};Here’s what happens per request:
- No Accept header or prefers HTML →
OpenNavServer.negotiate()returns the HTML withVary: AcceptandLink: rel="alternate". - Prefers Markdown,
.mdexists on disk → served directly from the static asset store withVary: Accept. Zero conversion cost. - Prefers Markdown, no
.mdon disk →OpenNavServer.negotiate()converts HTML to Markdown on-the-fly. - Unsupported type →
OpenNavServer.negotiate()returns406 Not AcceptablewithVary: Accept.
This pattern works for both Workers static
assets
and Pages
Functions.
If your site is fully static (no run_worker_first), you don’t need any of this
— Cloudflare serves .md files directly with zero Worker invocation.
Configuration
Section titled “Configuration”interface OpenNavServerOptions { /** Content types the server can produce, in priority order. * Defaults to ["text/html", "text/markdown"]. */ produces?: readonly string[];
/** Optional layout stripping before Markdown conversion. */ contentExtraction?: OpenNavContentExtractionOptions;}Choosing a Method
Section titled “Choosing a Method”OpenNavServer exposes three methods instead of one because real-world servers
often need to decide what to do before the expensive work happens — fetching
HTML, rendering a page, or converting to Markdown. Each method does one thing
so you can compose them when you need control, or take the default when you
don’t.
negotiate() — the default for most routes
Section titled “negotiate() — the default for most routes”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
app.get("/docs/:slug", async (c) => { const htmlResponse = await renderPage(c.req.param("slug"));
const result = await opennav.negotiate({ request: c.req.raw, htmlResponse, });
if (result.isErr()) return c.text("Internal error", 500);
// result.value is already the right response: // HTML (with Vary + Link) if the client prefers text/html // Markdown if the client prefers text/markdown // 406 Not Acceptable if no type matches return result.value;});negotiate() is the full pipeline: accept header → decision → response. Use
it when you already have an HTML response in hand and want the right thing to
happen based on what the client asked for. It covers all three outcomes in one
call. This is the right choice for straightforward SSR routes, Hono handlers,
and any path where rendering the page is cheap.
All framework examples above use negotiate() for this reason.
accept() — when you need the decision before doing work
Section titled “accept() — when you need the decision before doing work”import { OpenNavServer } from "@opennav-ai/opennav/server";
const opennav = new OpenNavServer();
app.get("/docs/:slug", async (c) => { const decision = opennav.accept(c.req.raw);
// An expensive SSR render is about to happen, but the client // only wants Markdown — and we might already have a cached copy. if (decision === "text/markdown") { const cachedMd = await getCachedMarkdown(c.req.param("slug"));
if (cachedMd) { return new Response(cachedMd, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Vary": "Accept", }, }); } }
// Either the client wants HTML, or the Markdown cache missed. // Either way, render the page and let negotiate() handle it. const htmlResponse = await renderPage(c.req.param("slug"));
const result = await opennav.negotiate({ request: c.req.raw, htmlResponse, });
if (result.isErr()) return c.text("Internal error", 500); return result.value;});accept() parses the request’s Accept header against the configured
produces list and returns the content type decision. It is synchronous and
does no I/O — just a string parse. Use it when you want to branch before
the expensive work begins:
- Pre-built
.mdfiles on disk. In a Cloudflare Worker or static file server, callaccept()first, then fetch the static.mddirectly if the decision is"text/markdown". Never invoke the HTML render at all. - Expensive rendering. If your page hits a database or external API, you can skip the full render when Markdown is already cached, as shown above.
- Early 406 rejection. If the client asks for a type you don’t produce, short-circuit before touching your origin at all.
accept() replaces the need to import AcceptHeaderNegotiator directly. It
uses the same produces list configured on the OpenNavServer instance, so
you get consistent decisions across accept() and negotiate().
toMarkdown() — when you already know Markdown is needed
Section titled “toMarkdown() — when you already know Markdown is needed”toMarkdown() converts the HTML body to Markdown without inspecting the
Accept header. Use it when the decision is already settled — either because
you branched on accept() and the Markdown cache missed, or because the
endpoint itself only serves Markdown.
Conversion fallback after a cache miss:
const opennav = new OpenNavServer();
app.get("/docs/:slug", async (c) => { const decision = opennav.accept(c.req.raw);
if (decision === "text/markdown") { const cached = await getCachedMd(c.req.param("slug")); if (cached) return cached;
// Cache missed — render HTML and convert. No need for negotiate() // because we already know the client wants Markdown. const htmlResponse = await renderPage(c.req.param("slug")); const result = await opennav.toMarkdown({ request: c.req.raw, htmlResponse, });
if (result.isErr()) return c.text("Internal error", 500); return result.value; }
const htmlResponse = await renderPage(c.req.param("slug")); const result = await opennav.negotiate({ request: c.req.raw, htmlResponse }); if (result.isErr()) return c.text("Internal error", 500); return result.value;});Markdown-only API endpoint:
const opennav = new OpenNavServer();
// This route always returns Markdown — no content negotiation.app.get("/api/:slug.md", async (c) => { const htmlResponse = await renderPage(c.req.param("slug"));
const result = await opennav.toMarkdown({ request: c.req.raw, htmlResponse, });
if (result.isErr()) return c.text("Internal error", 500); return result.value;});toMarkdown() respects the same contentExtraction options as negotiate().
It derives page metadata from the request URL automatically, so link rewriting
and path normalization still work.
Why these are separate methods
Section titled “Why these are separate methods”The split follows a design rule: each method has one responsibility.
| Method | Responsibility |
|---|---|
accept() | Content type decision |
toMarkdown() | HTML-to-Markdown conversion |
negotiate() | Orchestration (calls accept() then acts) |
For the 90% case — an SSR route where rendering is cheap — negotiate() is
the only call you need. The other two methods exist so callers with more
complex setups (static .md files, caches, expensive renders) can compose the
pieces without re-implementing logic the SDK already owns or importing engine
internals.
Quick-reference: which method when
Section titled “Quick-reference: which method when”| If you… | Use… |
|---|---|
| Have HTML and want the right response based on Accept | negotiate() |
| Need the Accept decision before fetching or rendering | accept() |
| Have HTML and know Markdown is the right output | toMarkdown() |
Serve pre-built .md files alongside HTML | accept() to branch, then negotiate() or toMarkdown() to convert |
| Build a Markdown-only API endpoint | toMarkdown() |
Out of Scope
Section titled “Out of Scope”The OpenNavServer handles per-request HTML-to-Markdown content negotiation and
conversion. It does not:
- Check for or serve pre-built static
.mdfiles (useaccept()to branch into your own static-asset logic). - Rewrite internal links to
.mdendpoints. - Cache converted Markdown (wrap in your own CDN/cache headers).
- Generate
llms.txtorllms-full.txtat runtime (those are build-time artifacts). - Auto-discover pages or slugs (page metadata is derived from the request URL).
For static builds, continue using the Astro or Next.js guides.