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