One URL, three formats — how Content Negotiation works in Bindly
bind.ly/@space/binding serves HTML for browsers, Markdown for LLMs, and structured metadata for crawlers. How we made one URL work for all three without maintaining separate endpoints.
- ?format=md returns raw Markdown — the primary LLM access path
- Accept: text/markdown header also triggers Markdown — for proper HTTP clients
# One URL, three formats — how Content Negotiation works in Bindly
`bind.ly/@bindly/cloudflare-native-stack` is one URL. Hit it with a browser, you get a styled page. Hit it with `curl -H "Accept: text/markdown"`, you get raw Markdown. Hit it as a search crawler, you get HTML with OG tags, JSON-LD, and a canonical link.
We didn't want three separate endpoints. Maintaining `/api/markdown/...` alongside `/@space/...` alongside some SEO-specific path would be a nightmare. Content Negotiation is HTTP's built-in solution for this.
## How we pick the format
The Gateway Worker checks signals in order:
```typescript
function wantsMarkdown(c: Context): boolean {
if (c.req.query('format') === 'md') return true
const accept = c.req.header('Accept') ?? ''
if (accept.includes('text/markdown')) return true
if (accept.includes('text/plain')) return true
return false
}
```
`?format=md` wins over everything. We made this the primary LLM path because it's URL-shareable — you can paste `bind.ly/@bindly/cloudflare-native-stack?format=md` into a chat, a README, a script, and it just works. No Accept header configuration, no SDK.
The `textUrl` field in every MCP response gives you this URL pre-built:
```json
{
"textUrl": "https://bind.ly/@bindly/cloudflare-native-stack?format=md"
}
```
So Claude, after creating or retrieving a Binding via MCP, always has the Markdown URL ready to cite or share.
## The HTML path
When you don't send any negotiation signals, you get SSR HTML. That HTML includes everything a search crawler needs:
```html
<meta property="og:type" content="article" />
<meta property="og:title" content="Why we went all-in on Cloudflare" />
<link rel="canonical" href="https://bind.ly/@bindly/cloudflare-native-stack" />
<script type="application/ld+json">
{
"@type": "Article",
"datePublished": "2026-04-08",
"author": { "@type": "Person", "name": "seongjaeryu" },
"isPartOf": { "@type": "CollectionPage", "url": "https://bind.ly/@bindly" }
}
</script>
```
Thin content (under 200 characters) gets `<meta name="robots" content="noindex">`. We're not interested in Google indexing stub pages.
## Help pages work the same way
Every page under `/help/*` supports the same negotiation:
```bash
# Human
curl https://bind.ly/help/mcp/tools
# LLM
curl "https://bind.ly/help/mcp/tools?format=md"
```
The content lives in KV as Markdown. For HTML requests we render it with the design system wrapper and strip the first H1 (it becomes the header card title). For Markdown requests we return the raw KV value untouched, H1 and all.
`mcp_help` uses this — when an LLM calls it, we fetch `bind.ly/help/{topic}?format=md` and return whatever comes back. Updating help content means updating KV. No MCP redeployment.
## URL normalization
We normalize all the legacy URL shapes before doing any content lookup. One 301, no chains:
| Input | Canonical |
| --------------------------- | ---------------------------------- |
| `/help/` | `/help` |
| `/help/mcp/tools/` | `/help/mcp/tools` |
| `/help/getting-started.md` | `/help/getting-started?format=md` |
| `/help/foo.md/` | `/help/foo?format=md` |
| `/help/index` | `/help` |
Query strings survive the redirect. `/help/mcp.md?lang=en` becomes `/help/mcp?format=md&lang=en` in one hop. We were careful about this — multi-hop redirects are a silent killer for anything automated.
## The cache problem
`Vary: Accept` is essential. Without it, a CDN might serve a cached Markdown response to a browser, or cached HTML to an LLM.
```http
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
Vary: Accept
```
One thing to know about Cloudflare Workers: the CF edge CDN doesn't automatically cache dynamic Worker responses even with `s-maxage`. These headers are honored by downstream caches (browsers, other CDNs) but not the Cloudflare edge itself. If you want CF edge caching you have to explicitly call `caches.default.put`. We haven't needed that yet — the 60s browser cache and the KV fallback have been enough.