
# Using Cloudflare KV as a CMS — deploy content without deploying code

Early in v1 development, we hit an annoying problem: fixing a typo in the landing page headline required a full Worker deployment. Build, upload, verify. For a solo developer maintaining a live service, that friction adds up fast — small content fixes get batched or delayed.

The solution was obvious once we thought about it: stop bundling content into Worker code.

## Two kinds of content, two different rates of change

Bindly's Gateway Worker serves content that changes at different rates:

- **Routing logic** — changes rarely, requires code deployment
- **SSR templates** — changes occasionally, requires code deployment
- **Landing page copy** — changes frequently, should not require code deployment
- **Help documentation** — changes frequently, should not require code deployment
- **llms.txt, ai-plugin.json** — changes occasionally, should not require code deployment

When content and code share a deployment cycle, you end up either deploying constantly for minor edits or batching content changes with unrelated code changes. Neither is good.

The fix: store content in Cloudflare KV and read it at request time.

## Two KV namespaces, two purposes

```toml
# v1/packages/gateway/wrangler.toml

[[kv_namespaces]]
binding = "CACHE"
id = "e5da5736a08c4af185aff78c2e738aa4"

[[kv_namespaces]]
binding = "LANDING"
id = "09f29269d5e144bdb938206216078534"
```

**CACHE** is ephemeral. Rendered SSR HTML for public Binding pages, 60-second TTL. It exists for resilience — if the API Worker goes down, the Gateway can serve stale cached pages. Values expire automatically.

**LANDING** is permanent. Landing page HTML, help Markdown files, `llms.txt`, `ai-plugin.json`. Values stay until explicitly overwritten.

## The content directory

```text
v1/packages/gateway/content/
├── index.html
├── llms.txt
├── ai-plugin.json
└── help/
    ├── index.md
    ├── getting-started.md
    ├── mcp.md
    ├── mcp/
    │   └── tools.md
    ├── api.md
    ├── spaces.md
    ├── bindings.md
    ├── sets.md
    └── faq.md
```

A shell script uploads all of it:

```bash
#!/usr/bin/env bash
# v1/packages/gateway/scripts/upload-content.sh

FLAG="${1:-}"
KV_CMD="wrangler kv put --namespace-id 09f29269d5e144bdb938206216078534"
[ "$FLAG" = "--remote" ] && KV_CMD="$KV_CMD --remote"

$KV_CMD "index.html"              --path=content/index.html
$KV_CMD "llms.txt"                --path=content/llms.txt
$KV_CMD "ai-plugin.json"          --path=content/ai-plugin.json
$KV_CMD "help/index.md"           --path=content/help/index.md
$KV_CMD "help/getting-started.md" --path=content/help/getting-started.md
$KV_CMD "help/mcp.md"             --path=content/help/mcp.md
$KV_CMD "help/mcp/tools.md"       --path=content/help/mcp/tools.md
# ... etc
```

Updating help documentation in production:

```bash
# Edit content/help/mcp/tools.md
# Then:
bash scripts/upload-content.sh --remote
# Done. No Worker redeployment. Changes live within seconds.
```

That's the entire workflow. Edit, run one script, done.

## How the Gateway reads it

```typescript
// Landing page
app.get('/', async (c) => {
  const html = await c.env.LANDING.get('index.html')
  if (!html) return c.text('Service unavailable', 503)
  return c.html(html)
})

// Help pages
app.get('/help/:page{.+}?', async (c) => {
  const page = c.req.param('page') || 'index'
  const md = await c.env.LANDING.get(`help/${page}.md`)
  if (!md) return c.notFound()

  if (wantsMarkdown(c)) {
    return c.text(md, 200, { 'Content-Type': 'text/markdown' })
  }

  return c.html(renderHelpPage(page, md))
})

// AI discovery
app.get('/llms.txt', async (c) => {
  const txt = await c.env.LANDING.get('llms.txt')
  return c.text(txt ?? '')
})
```

KV reads are fast — the value is served from Cloudflare's edge, co-located with the Worker. Sub-millisecond in practice.

## The help page pattern: one Markdown source, two outputs

Help pages are stored as Markdown. The Gateway renders them two ways:

**For browsers** — rendered to HTML with the design system wrapper: title from `HELP_PAGE_TITLES` map, two-card layout matching the SPA's `SpaceHeader`, tab navigation, design tokens inline. The first H1 in the Markdown is stripped (it becomes the header card title instead of appearing in the body).

**For LLMs** — the raw Markdown, unmodified, with `Content-Type: text/markdown`. The H1 is kept. This is the format `mcp_help` uses.

```typescript
function renderHelpPage(page: string, md: string): string {
  const title = HELP_PAGE_TITLES[page] ?? page

  // Strip first H1 — it becomes the header card title
  const body = md.replace(/^#\s+.+\n/, '')
  const htmlBody = markdownToHtml(body)

  return renderShell({
    title,
    content: `
      <div class="detail-page">
        <div class="detail-card">
          <h1 class="page-title">${title}</h1>
        </div>
        <div class="detail-card">
          <nav class="tab-nav">${renderHelpTabs(page)}</nav>
          <div class="markdown-body">${htmlBody}</div>
        </div>
      </div>
    `
  })
}
```

One Markdown file serves both audiences. No duplication, no sync issues.

## The DESIGN_CSS sync problem

The Gateway renders HTML that must match the SPA's visual design. This means the CSS variables defined in `v1/packages/web/src/index.css` must also exist in the Gateway's `DESIGN_CSS` constant.

This is the one maintenance burden of the pattern: when a design token changes in the SPA, it must also be updated in the Gateway. Two places instead of one.

The tradeoff is acceptable because design tokens change infrequently, the Gateway renders only a few page types, and the alternative (importing the SPA's CSS in the Worker bundle) adds significant bundle size for minimal benefit. We added a comment: *"Update both index.css AND gateway DESIGN_CSS when changing tokens."* That reminder has caught at least two inconsistencies.

## What this pattern is good for

KV-as-CMS works well when content updates faster than code, the rendering logic is stable even when content changes, and you want instant deployment with no build step.

It's not a general CMS. There's no editor UI, no preview, no versioning (beyond what git provides for the source files). For our use case — a developer maintaining documentation and landing copy — it's exactly the right weight.

The simplest possible content deployment: edit Markdown locally, run one script, live in seconds.