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
npm install- 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. - Put your static site in
public/, setnameand a route inwrangler.jsonc, thennpm run deploy. - Add the webhook
https://YOUR-SITE/api/superduperseo/webhookin SuperDuperSEO andnpx 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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=