Skip to content

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.

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.

Client Accept headerResponse
Prefers text/markdownHTML body is converted to Markdown. Content-Type: text/markdown; charset=utf-8.
Prefers text/htmlOriginal 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.

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;
});
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;
};
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;
}
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);
},
};
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.

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

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.

ApproachBest for
Static .md files on diskPre-generated pages. Zero CPU, zero cost. Deploy OpenNav’s build output.
Cloudflare Markdown for AgentsAny origin HTML. Zero application code. Requires Pro or Business plan.
OpenNavServer in a WorkerFull control over conversion, custom stripping rules, or platforms without Cloudflare’s native feature.

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:

  1. No Accept header or prefers HTMLOpenNavServer.negotiate() returns the HTML with Vary: Accept and Link: rel="alternate".
  2. Prefers Markdown, .md exists on disk → served directly from the static asset store with Vary: Accept. Zero conversion cost.
  3. Prefers Markdown, no .md on diskOpenNavServer.negotiate() converts HTML to Markdown on-the-fly.
  4. Unsupported typeOpenNavServer.negotiate() returns 406 Not Acceptable with Vary: 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.

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;
}

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 .md files on disk. In a Cloudflare Worker or static file server, call accept() first, then fetch the static .md directly 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.

The split follows a design rule: each method has one responsibility.

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

If you…Use…
Have HTML and want the right response based on Acceptnegotiate()
Need the Accept decision before fetching or renderingaccept()
Have HTML and know Markdown is the right outputtoMarkdown()
Serve pre-built .md files alongside HTMLaccept() to branch, then negotiate() or toMarkdown() to convert
Build a Markdown-only API endpointtoMarkdown()

The OpenNavServer handles per-request HTML-to-Markdown content negotiation and conversion. It does not:

  • Check for or serve pre-built static .md files (use accept() to branch into your own static-asset logic).
  • Rewrite internal links to .md endpoints.
  • Cache converted Markdown (wrap in your own CDN/cache headers).
  • Generate llms.txt or llms-full.txt at 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.