
# Gateway Worker: SSR for crawlers, SPA proxy for users — same URL

`bind.ly/@bindly/cloudflare-native-stack` is one URL. Googlebot gets SEO-optimized HTML with structured metadata. An authenticated user gets the React SPA with the full editor. An LLM fetches it with `?format=md` and gets raw Markdown.

This is the Gateway Worker's entire job: inspect each request and decide how to serve it. It's a few hundred lines of Hono routing code, but the decisions it makes shape everything about how Bindly feels to use.

## The routing priority

The Gateway processes requests in priority order:

```typescript
const app = new Hono<{ Bindings: Env }>()

// 1. Static files — served from KV, no auth check
app.get('/favicon.ico', ...)
app.get('/robots.txt', ...)
app.get('/sitemap.xml', ...)
app.get('/llms.txt', ...)
app.get('/llms-full.txt', ...)
app.get('/.well-known/ai-plugin.json', ...)

// 2. Help pages — Content Negotiation, 301 normalization, 405 guard
app.all('/help', methodGuard)
app.get('/help/', redirectToHelp)
app.get('/help/:page{.+}', serveHelpPage)

// 3. Share links — noindex, Content Negotiation
app.get('/b/:shareId', serveBindingShare)
app.get('/s/:shareId', serveSetShare)

// 4. Always-SPA paths — no SSR, proxy directly
app.get('/login', spaProxy)
app.get('/register', spaProxy)
app.get('/personal/*', spaProxy)
app.get('/settings', spaProxy)
app.get('/search', spaProxy)
app.get('/spaces/*', spaProxy)
app.get('/explore', spaProxy)
app.get('/getting-started', spaProxy)
app.get('/invitations/*', spaProxy)

// 5. Smart branching — @space routes
app.get('/@:space', smartBranch)
app.get('/@:space/bindings', smartBranch)
app.get('/@:space/sets', smartBranch)
app.get('/@:space/members', spaProxy)       // always SPA (auth-sensitive)
app.get('/@:space/:binding', smartBranch)
app.get('/@:space/*', spaProxy)             // catch-all for deep links

// 6. Root
app.get('/', spaProxy)                      // always SPA

// 7. Legacy redirect
app.get('/app/*', legacyRedirect)
```

## The smart branch

For routes that can be either SSR or SPA, `smartBranch` checks for a session cookie:

```typescript
async function smartBranch(c: Context): Promise<Response> {
  const sessionCookie = getCookie(c, 'bindly_session')

  if (sessionCookie) {
    // Authenticated user — proxy to SPA
    return spaProxy(c)
  }

  // Unauthenticated — render SSR
  return renderSSR(c)
}
```

That's the entire decision. We don't validate the JWT here — that would require calling D1 on every single request. We trust that a session cookie means "this person has been authenticated." The SPA validates the JWT when it loads and handles token expiry client-side.

For crawlers (Googlebot, ClaudeBot, etc.), there's no session cookie. They always get SSR. That's exactly what we want.

## The SSR path

SSR rendering calls the API Worker via Service Binding, then renders an HTML page:

```typescript
async function renderSSR(c: Context): Promise<Response> {
  const space = c.req.param('space')
  const binding = c.req.param('binding')
  const cacheKey = `ssr:${space}:${binding}`

  let data: BindingSSRData

  try {
    const res = await c.env.API.fetch(
      new Request(`https://api/api/public/@${space}/${binding}`)
    )

    if (!res.ok) {
      if (res.status === 404) return c.html(renderNotFoundPage(), 404)
      throw new Error(`API error: ${res.status}`)
    }

    data = await res.json()

    // Cache for fallback
    await c.env.CACHE.put(cacheKey, JSON.stringify(data), { expirationTtl: 300 })

  } catch (err) {
    // API unavailable — try KV cache
    const cached = await c.env.CACHE.get(cacheKey)
    if (!cached) return c.html(renderErrorPage(), 503)

    data = JSON.parse(cached)
    const res = renderBindingHTML(c, data)
    res.headers.set('X-Cache-Status', 'stale-fallback')
    return res
  }

  // Content negotiation check
  if (wantsMarkdown(c)) {
    return c.text(data.content, 200, {
      'Content-Type': 'text/markdown',
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
      'Vary': 'Accept'
    })
  }

  return renderBindingHTML(c, data)
}
```

`renderBindingHTML` generates the full HTML page: design tokens, OG meta tags, JSON-LD, canonical URL, Markdown-to-HTML rendered content, and `<meta name="robots" content="noindex">` if the content is under 200 characters. All in a single Worker function, no framework.

## The SPA proxy path

The SPA proxy forwards the request to Cloudflare Pages:

```typescript
async function spaProxy(c: Context): Promise<Response> {
  const url = new URL(c.req.url)
  url.hostname = 'bindly-web.pages.dev'  // Cloudflare Pages internal URL

  const response = await fetch(new Request(url, c.req.raw))

  const res = new Response(response.body, response)
  res.headers.set('Cache-Control', 'private, no-store')
  res.headers.set('Vary', 'Cookie')
  return res
}
```

Pages serves the React SPA's `index.html` for all paths via the standard `_redirects` fallback. TanStack Router handles client-side routing after load.

## Why not just redirect?

We considered the simple version: unauthenticated visitors get a 301 redirect to an SSR subdomain, authenticated users use the SPA at the main domain.

This breaks the fundamental requirement: **one URL**. A Binding's URL should be the same whether you're logged in or not. Links shared in emails, Slack, LLM responses — they must work for everyone. Redirecting based on auth state would break those links for unauthenticated users.

The smart branch serves different responses from the same URL while keeping the URL stable.

## The `/@:space/members` exception

Most `/@space/*` routes use smart branching. The members page is always SPA:

```typescript
app.get('/@:space/members', spaProxy)
```

The members page shows email addresses and invitation management. SSR would expose this to crawlers. Even for public spaces, the members list is rendered client-side and filtered — public/official spaces show names and avatars only, private spaces require auth.

## The `/` route

Root always goes to the SPA, never SSR. The SPA's `_auth/index.tsx` handles the split client-side:

- Authenticated: dashboard (spaces list, recent activity)
- Unauthenticated: onboarding page (feature cards, sign-in CTA, MCP quickstart)

We made this decision because the onboarding page has animations and interactive elements that work poorly as static SSR. The SEO value of the landing page is served by the separately maintained `index.html` in KV — different content, different purpose.

## Cache-Control strategy summary

| Path | Auth | Response | Cache-Control |
| ---- | ---- | -------- | ------------- |
| `/@space/binding` | No | SSR HTML | `public, s-maxage=60, swr=300` |
| `/@space/binding?format=md` | No | Markdown | `public, s-maxage=60, swr=300` |
| `/@space/binding` | Yes | SPA proxy | `private, no-store` |
| `/login`, `/settings` | Any | SPA proxy | `private, no-store` |
| `/help/*` | Any | HTML/Markdown | `public, s-maxage=60, swr=300` |
| `/llms.txt` | Any | Text | `public, s-maxage=3600` |
| `/sitemap.xml` | Any | XML | `public, s-maxage=3600` |

The 60-second `s-maxage` on SSR responses means a Binding can be updated and the cached version served for up to a minute before invalidation. Acceptable for a knowledge platform — near-real-time freshness without the complexity of cache purging on every write.