Docs menu
Markdown

Starter: Cloudflare (Worker or Pages)

A complete, SEO-correct blog on your domain from SuperDuperSEO, rendered on the server. Copy the files you need into your site, keep your design, and keep every URL. Every file is below; this page as markdown is one fetch: https://superduperseo.com/docs/starter-cloudflare.md.

Your blog, rendered on the server at Cloudflare's edge from SuperDuperSEO, inside any static site (plain HTML, Vite/React, AI-built sites). Search engines and AI crawlers get the full article on their first request: title, description, canonical, robots, Open Graph, one JSON-LD block, the text. No "Loading…" pages.

src/blog.ts does the work. Make src/layout.ts look like your site (header, footer, CSS).

Option A: Worker with static assets

  1. npm install
  2. Create a site key in SuperDuperSEO (Domains, your site, Content API), then npx wrangler secret put SUPERDUPERSEO_CONTENT_KEY. Never put it in code or in the browser.
  3. Put your static site in public/, set name and a route in wrangler.jsonc, then npm run deploy.
  4. Add the webhook https://YOUR-SITE/api/superduperseo/webhook in SuperDuperSEO and npx wrangler secret put SUPERDUPERSEO_WEBHOOK_SECRET.

Already have a worker? Copy src/ into your project and add this at the top of your fetch():

const blog = await handleBlog(request, env, ctx)
if (blog) return blog

Then add /blog, /blog/*, /sitemap.xml, /llms.txt, /*.txt and /api/superduperseo/* to run_worker_first (or let the worker see every request). Remove any code that loads posts in the browser.

Option B: Cloudflare Pages

Copy src/ and functions/ into your Pages project, then set SUPERDUPERSEO_CONTENT_KEY (and SUPERDUPERSEO_WEBHOOK_SECRET) as encrypted environment variables in the Pages project settings. functions/blog/[[path]].ts serves the blog; functions/[file].ts serves the sitemap, llms.txt and the IndexNow key file; functions/api/superduperseo/webhook.ts takes the webhook. If you already have a functions/blog/[[path]].js, replace it.

What you get

  • /blog (paginated), /blog/{slug}, /blog/author/{slug}, /blog/topic/{slug}
  • Our 301s (moved pages) and 410s (removed pages), a 301 from a trailing slash, real 404s
  • /sitemap.xml, /blog/rss.xml, /llms.txt, the IndexNow key file
  • Pages cached at the edge for 5 minutes. Our webhook drops the cached copy as soon as a page changes. If SuperDuperSEO is unreachable, the last good copy is served.

SuperDuperSEO then checks your live pages and marks the site Verified.

Files

package.json

{
  "name": "superduperseo-cloudflare-starter",
  "private": true,
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy"
  },
  "dependencies": {
    "@superduperseo/content": "^0.1.0"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20250214.0",
    "typescript": "^5.7.0",
    "wrangler": "^4.0.0"
  }
}

wrangler.jsonc

{
  // Cloudflare Worker + static assets. For Cloudflare Pages, deploy the
  // public/ folder with the functions/ folder instead (see README).
  "name": "my-site",
  "main": "src/worker.ts",
  "compatibility_date": "2025-09-01",
  "assets": {
    "directory": "./public",
    "binding": "ASSETS",
    // The worker sees blog, sitemap, llms.txt, key file and webhook requests first.
    "run_worker_first": ["/blog", "/blog/*", "/sitemap.xml", "/llms.txt", "/*.txt", "/api/superduperseo/*"]
  }
  // Secrets (not here): npx wrangler secret put SUPERDUPERSEO_CONTENT_KEY
  //                    npx wrangler secret put SUPERDUPERSEO_WEBHOOK_SECRET
}

src/worker.ts

/**
 * Cloudflare Worker with static assets (wrangler.jsonc): the blog is rendered
 * here, everything else is your static site. If you already have a worker
 * (e.g. simonsovic.com's worker.js), add the two handleBlog lines to its
 * fetch() before it serves assets.
 */
import { handleBlog, type Ctx, type Env } from './blog'

interface WorkerEnv extends Env {
  ASSETS: { fetch(request: Request): Promise<Response> }
}

export default {
  async fetch(request: Request, env: WorkerEnv, ctx: Ctx): Promise<Response> {
    const blog = await handleBlog(request, env, ctx)
    if (blog) return blog
    return env.ASSETS.fetch(request)
  },
}

src/blog.ts

/**
 * The blog, rendered on the server at Cloudflare's edge from SuperDuperSEO.
 * Every response is full HTML (title, description, canonical, robots, Open
 * Graph, one JSON-LD block, the article): what Google and AI crawlers read
 * on their first fetch. No browser-side loading.
 *
 *   /blog                 index (?page=2...)
 *   /blog/{slug}          post            (our 301s and 410s answered first)
 *   /blog/author/{slug}   author page
 *   /blog/topic/{slug}    topic page
 *   /blog/rss.xml         RSS feed
 *   /sitemap.xml          sitemap (merge with yours if you have one)
 *   /llms.txt             for AI search engines
 *   /{indexnow-key}.txt   IndexNow key file
 *   POST /api/superduperseo/webhook   our webhook: drops cached copies
 *
 * handleBlog() returns null for anything else, so the rest of your site keeps
 * working. Rendered pages are cached at the edge for 5 minutes.
 */
import { createClient, verifyWebhook, type AuthorPage, type Post, type PostSummary, type Site, type SuperDuperSeoClient } from '@superduperseo/content'
import { layout } from './layout'
import { esc } from './html'

export interface Env {
  /** From SuperDuperSEO -> Domains -> your site -> Content API (a secret, never in code). */
  SUPERDUPERSEO_CONTENT_KEY: string
  /** The webhook's signing secret, if you added the webhook. */
  SUPERDUPERSEO_WEBHOOK_SECRET?: string
  /** Only for staging or local development. */
  SUPERDUPERSEO_CONTENT_URL?: string
}

export interface Ctx { waitUntil(promise: Promise<unknown>): void }

interface EdgeCache {
  match(request: Request): Promise<Response | undefined>
  put(request: Request, response: Response): Promise<void>
  delete(request: Request | string): Promise<boolean>
}

const EDGE_TTL = 300
const BLOG = '/blog'

let client: SuperDuperSeoClient | null = null
let clientKey = ''
function cms(env: Env): SuperDuperSeoClient {
  if (!client || clientKey !== env.SUPERDUPERSEO_CONTENT_KEY) {
    client = createClient({ key: env.SUPERDUPERSEO_CONTENT_KEY, baseUrl: env.SUPERDUPERSEO_CONTENT_URL })
    clientKey = env.SUPERDUPERSEO_CONTENT_KEY
  }
  return client
}

const edgeCache = (): EdgeCache | null => (globalThis as unknown as { caches?: { default?: EdgeCache } }).caches?.default ?? null

const date = (iso: string) => new Date(iso).toLocaleDateString('en', { dateStyle: 'long', timeZone: 'UTC' })

function html(body: string, status = 200, extra: Record<string, string> = {}): Response {
  return new Response(body, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': `public, max-age=60, s-maxage=${EDGE_TTL}`, ...extra } })
}
const text = (body: string, type: string) => new Response(body, { headers: { 'Content-Type': `${type}; charset=utf-8`, 'Cache-Control': `public, max-age=300, s-maxage=${EDGE_TTL}` } })

function headFor(seo: { title: string; description: string | null; canonical: string; robots: string }): string {
  return `${seo.description ? `<meta name="description" content="${esc(seo.description)}">` : ''}<link rel="canonical" href="${esc(seo.canonical)}"><meta name="robots" content="${esc(seo.robots)}">`
}

function card(p: PostSummary): string {
  return `<article class="sds-card"><h2><a href="${esc(p.path)}">${esc(p.title)}</a></h2>${p.summary ? `<p>${esc(p.summary)}</p>` : ''}<p class="sds-meta"><time datetime="${p.published_at}">${date(p.published_at)}</time> · ${p.reading_time} min read</p></article>`
}

function renderPost(site: Site, post: Post): string {
  const img = post.feature_image
  const hero = img
    // The largest element: load it first (LCP), with its size (no layout shift).
    ? `<img src="${esc(img.url)}"${img.srcset ? ` srcset="${esc(img.srcset)}" sizes="(max-width: 760px) 100vw, 760px"` : ''} alt="${esc(img.alt ?? '')}"${img.width ? ` width="${img.width}"` : ''}${img.height ? ` height="${img.height}"` : ''} fetchpriority="high" class="sds-hero">${img.credit ? `<p class="sds-credit">Photo: ${img.credit.url ? `<a href="${esc(img.credit.url)}" rel="nofollow">${esc(img.credit.name)}</a>` : esc(img.credit.name)}</p>` : ''}`
    : ''
  const crumbs = post.breadcrumbs.map((b, i) => i < post.breadcrumbs.length - 1 ? `<a href="${esc(new URL(b.url).pathname)}">${esc(b.name)}</a>` : esc(b.name)).join(' / ')
  const toc = post.toc.filter(t => t.level === 2)
  const updated = post.updated_at !== post.published_at ? ` · Updated <time datetime="${post.updated_at}">${date(post.updated_at)}</time>` : ''
  const body = `<nav class="sds-crumbs" aria-label="Breadcrumb">${crumbs}</nav>
<article class="sds-post">
<h1>${esc(post.title)}</h1>
<p class="sds-meta"><time datetime="${post.published_at}">${date(post.published_at)}</time>${updated} · ${post.reading_time} min read</p>
${hero}
${toc.length > 2 ? `<nav class="sds-toc" aria-label="Contents"><ul>${toc.map(t => `<li><a href="#${esc(t.id)}">${esc(t.text)}</a></li>`).join('')}</ul></nav>` : ''}
${post.html}
</article>
${post.related.length ? `<aside class="sds-related"><h2>Related</h2><ul>${post.related.map(r => `<li><a href="${esc(new URL(r.url).pathname)}">${esc(r.title)}</a></li>`).join('')}</ul></aside>` : ''}`
  // post.head_html: description, canonical, robots, Open Graph, Twitter and the one JSON-LD block.
  return layout({ site, title: post.seo.title, head: post.head_html, body })
}

async function render(path: string, url: URL, env: Env): Promise<Response | null> {
  const c = cms(env)

  if (path === '/sitemap.xml') { const x = await c.sitemapXml(); return x ? text(x, 'application/xml') : null }
  if (path === '/llms.txt') { const t = await c.llmsTxt(); return t ? text(t, 'text/plain') : null }
  if (path === `${BLOG}/rss.xml`) { const x = await c.feedXml(); return x ? text(x, 'application/rss+xml') : null }
  const keyFile = /^\/([A-Za-z0-9-]{8,128})\.txt$/.exec(path)
  if (keyFile) { const k = await c.indexNowKey(); return k && k === keyFile[1] ? text(k, 'text/plain') : null }
  if (path !== BLOG && !path.startsWith(`${BLOG}/`)) return null

  // Our redirects first: 301 for moved pages, 410 for removed ones.
  const redirect = (await c.redirects()).find(r => r.from === path)
  if (redirect?.to) return Response.redirect(redirect.to, 301)
  if (redirect) return html(layout({ site: await c.site(), title: 'Gone', head: '<meta name="robots" content="noindex">', body: '<h1>This page was removed</h1>' }), 410)

  const site = await c.site()
  if (!site) return null

  if (path === BLOG) {
    const page = Math.max(1, Number(url.searchParams.get('page')) || 1)
    const index = await c.posts(page)
    if (!index || (page > 1 && index.posts.length === 0)) return null
    const canonical = `${site.url.replace(/\/$/, '')}${BLOG}${page > 1 ? `?page=${page}` : ''}`
    const nav = `<nav class="sds-pages">${page > 1 ? `<a href="${BLOG}${page === 2 ? '' : `?page=${page - 1}`}">Newer</a>` : ''} ${index.next ? `<a href="${BLOG}?page=${page + 1}">Older</a>` : ''}</nav>`
    return html(layout({
      site, title: `Blog - ${site.name}`,
      head: headFor({ title: `Blog - ${site.name}`, description: site.description, canonical, robots: 'index, follow, max-image-preview:large' }) + `<link rel="alternate" type="application/rss+xml" href="${esc(site.feed)}">`,
      body: `<h1>Blog</h1>${index.posts.map(card).join('')}${nav}`,
    }))
  }

  const [, kind, slug] = /^\/blog\/(?:(author|topic)\/)?([^/]+)$/.exec(path) ?? []
  if (!slug) return null
  if (kind === 'author' || kind === 'topic') {
    const page = kind === 'author' ? await c.author(slug) : await c.topic(slug)
    if (!page) return null
    const bio = kind === 'author' ? (page as AuthorPage).bio : null
    const intro = bio ? `<p>${esc(bio)}</p>` : ''
    return html(layout({ site, title: page.seo.title, head: page.head_html, body: `<h1>${esc(page.name)}</h1>${intro}${page.posts.map(card).join('')}` }))
  }
  const hit = await c.resolve(path)
  return hit?.type === 'post' ? html(renderPost(site, hit.post)) : null
}

/**
 * Answer a blog request, or return null to let the rest of the site handle
 * it. A /blog path that matches nothing gets a real 404 (never a 200 "not
 * found" page, which search engines treat as a soft 404).
 */
export async function handleBlog(request: Request, env: Env, ctx?: Ctx): Promise<Response | null> {
  const url = new URL(request.url)

  if (url.pathname === '/api/superduperseo/webhook' && request.method === 'POST') {
    const body = await request.text()
    const event = await verifyWebhook(env.SUPERDUPERSEO_WEBHOOK_SECRET ?? '', body, request.headers.get('superduperseo-signature'))
    if (!event) return new Response('Invalid signature', { status: 401 })
    cms(env).clearCache()
    const cache = edgeCache()
    if (cache) {
      for (const p of [event.path, BLOG, `${BLOG}/rss.xml`, '/sitemap.xml', '/llms.txt']) {
        if (p) await cache.delete(`${url.origin}${p}`)
      }
    }
    return Response.json({ ok: true })
  }
  if (request.method !== 'GET' && request.method !== 'HEAD') return null

  // One URL per page: no trailing slash.
  if (url.pathname.length > 1 && url.pathname.endsWith('/') && (url.pathname.startsWith(`${BLOG}/`))) {
    return Response.redirect(`${url.origin}${url.pathname.replace(/\/+$/, '')}${url.search}`, 301)
  }
  const path = url.pathname

  const cache = edgeCache()
  const cacheKey = new Request(url.toString(), { method: 'GET' })
  if (cache) {
    const cached = await cache.match(cacheKey)
    if (cached) return cached
  }

  let res: Response | null
  try {
    res = await render(path, url, env)
  } catch (err) {
    console.error('[superduperseo] render failed:', err)
    return path === BLOG || path.startsWith(`${BLOG}/`) ? new Response('Temporarily unavailable', { status: 503, headers: { 'Retry-After': '60' } }) : null
  }
  if (!res && (path === BLOG || path.startsWith(`${BLOG}/`))) {
    res = html(layout({ site: await cms(env).site().catch(() => null), title: 'Not found', head: '<meta name="robots" content="noindex">', body: '<h1>Page not found</h1><p><a href="/blog">Back to the blog</a></p>' }), 404)
  }
  if (res && res.status === 200 && cache && ctx) ctx.waitUntil(cache.put(cacheKey, res.clone()))
  return res
}

src/layout.ts

/**
 * Your site's frame around every blog page: make it look like the rest of
 * your site (header, footer, fonts, CSS). Keep <html lang>, the <title> and
 * ${head} as they are: they carry the SEO tags.
 */
import type { Site } from '@superduperseo/content'
import { esc } from './html'

export function layout(args: { site: Site | null; title: string; head: string; body: string }): string {
  const { site, title, head, body } = args
  const name = site?.name ?? ''
  return `<!doctype html>
<html lang="${esc(site?.language ?? 'en')}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(title)}</title>
${head}
<style>
  body { max-width: 760px; margin: 0 auto; padding: 24px 16px; font: 17px/1.65 system-ui, sans-serif; color: #1a1a1a; }
  a { color: #3a5bd9; } img { max-width: 100%; height: auto; }
  .sds-meta, .sds-credit, .sds-crumbs { color: #666; font-size: 14px; }
  .sds-card { margin: 0 0 32px; } .sds-hero { width: 100%; border-radius: 8px; }
</style>
</head>
<body>
<header><a href="/">${esc(name)}</a> · <a href="/blog">Blog</a></header>
<main>
${body}
</main>
<footer><p>© ${new Date().getFullYear()} ${esc(name)}</p></footer>
</body>
</html>`
}

src/html.ts

/** Escape text for HTML text and attribute values. */
export const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

functions/blog/[[path]].ts

/** Cloudflare Pages: /blog and everything under it (kickmyass.io's shape). */
import { handleBlog, type Env } from '../../src/blog'

interface PagesContext { request: Request; env: Env; waitUntil(p: Promise<unknown>): void; next(): Promise<Response> }

export async function onRequest(context: PagesContext): Promise<Response> {
  return (await handleBlog(context.request, context.env, context)) ?? context.next()
}

functions/[file].ts

/** Cloudflare Pages: /sitemap.xml, /llms.txt and the IndexNow key file; anything else falls through to your site. */
import { handleBlog, type Env } from '../src/blog'

interface PagesContext { request: Request; env: Env; waitUntil(p: Promise<unknown>): void; next(): Promise<Response> }

export async function onRequest(context: PagesContext): Promise<Response> {
  const path = new URL(context.request.url).pathname
  if (path !== '/sitemap.xml' && path !== '/llms.txt' && !/^\/[A-Za-z0-9-]{8,128}\.txt$/.test(path)) return context.next()
  return (await handleBlog(context.request, context.env, context)) ?? context.next()
}

functions/api/superduperseo/webhook.ts

/** Cloudflare Pages: our webhook (POST), which drops cached copies of the changed page. */
import { handleBlog, type Env } from '../../../src/blog'

interface PagesContext { request: Request; env: Env; waitUntil(p: Promise<unknown>): void; next(): Promise<Response> }

export async function onRequestPost(context: PagesContext): Promise<Response> {
  return (await handleBlog(context.request, context.env, context)) ?? new Response('Not found', { status: 404 })
}

.dev.vars.example

# Local development only (wrangler dev). In production use: npx wrangler secret put ...
SUPERDUPERSEO_CONTENT_KEY=
SUPERDUPERSEO_WEBHOOK_SECRET=