What This Template Covers
This template provides the code structure and configuration patterns for deploying common SEO operations as Cloudflare Workers. Each section includes a working pattern you can adapt to your specific URL structure and content architecture.
Edge SEO with Cloudflare Workers lets you manage redirects, inject structured data, handle hreflang, and optimize meta tags — all without modifying your origin server or waiting for deployment cycles.
Section 1: Worker Project Setup
Initialize the project
npm create cloudflare@latest seo-worker
cd seo-worker
Configure routing in wrangler.toml
name = "seo-worker"
main = "src/index.ts"
compatibility_date = "2026-01-01"
# Route patterns - adjust to your domain
routes = [
{ pattern = "example.com/*", zone_name = "example.com" }
]
# KV namespace for redirect maps
[[kv_namespaces]]
binding = "REDIRECTS"
id = "your-kv-namespace-id"
Worker entry point structure
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// 1. Check redirects first (fastest exit)
const redirect = await checkRedirect(url, env)
if (redirect) return redirect
// 2. Fetch from origin
const response = await fetch(request)
// 3. Modify HTML responses
if (response.headers.get("content-type")?.includes("text/html")) {
return modifyHTML(response, url, env)
}
return response
}
}
Section 2: Bulk Redirect Management
Static redirect map (for smaller sets)
const REDIRECTS: Record<string, { target: string; status: number }> = {
"/old-blog/post-one": { target: "/blog/post-one", status: 301 },
"/legacy/about": { target: "/about", status: 301 },
"/temp-promo": { target: "/deals", status: 302 }
}
function checkStaticRedirect(url: URL): Response | null {
const rule = REDIRECTS[url.pathname]
if (rule) {
return Response.redirect(
new URL(rule.target, url.origin).toString(),
rule.status
)
}
return null
}
KV-based redirect map (for thousands of rules)
async function checkKVRedirect(url: URL, env: Env): Promise<Response | null> {
const rule = await env.REDIRECTS.get(url.pathname, "json")
if (rule) {
return Response.redirect(
new URL(rule.target, url.origin).toString(),
rule.status || 301
)
}
return null
}
Pattern-based redirects (for URL structure changes)
function checkPatternRedirect(url: URL): Response | null {
// Trailing slash normalization
if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
return Response.redirect(
url.origin + url.pathname.slice(0, -1) + url.search,
301
)
}
// Category URL migration: /category/slug → /blog/slug
const categoryMatch = url.pathname.match(/^\/category\/(.+)$/)
if (categoryMatch) {
return Response.redirect(`${url.origin}/blog/${categoryMatch[1]}`, 301)
}
return null
}
Section 3: HTML Response Modification
The HTMLRewriter approach
Cloudflare Workers provides HTMLRewriter for efficient streaming HTML modification:
async function modifyHTML(
response: Response,
url: URL,
env: Env
): Promise<Response> {
return new HTMLRewriter()
.on("head", new HeadElementHandler(url))
.on('meta[name="description"]', new MetaDescriptionHandler(url))
.on("title", new TitleHandler(url))
.transform(response)
}
Inject elements into <head>
class HeadElementHandler {
private url: URL
constructor(url: URL) {
this.url = url
}
element(element: Element) {
// Inject JSON-LD schema
const schema = getSchemaForURL(this.url)
if (schema) {
element.append(
`<script type="application/ld+json">${JSON.stringify(schema)}</script>`,
{ html: true }
)
}
// Inject hreflang tags
const hreflangs = getHreflangsForURL(this.url)
for (const tag of hreflangs) {
element.append(tag, { html: true })
}
}
}
Section 4: Schema Template Library
Article schema
function articleSchema(url: URL, meta: PageMeta) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: meta.title,
description: meta.description,
url: url.toString(),
datePublished: meta.publishedAt,
dateModified: meta.updatedAt,
author: {
"@type": "Organization",
name: meta.author || "Your Company"
},
publisher: {
"@type": "Organization",
name: "Your Company",
logo: { "@type": "ImageObject", url: `${url.origin}/logo.png` }
}
}
}
FAQ schema
function faqSchema(faqs: { question: string; answer: string }[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map(faq => ({
"@type": "Question",
name: faq.question,
acceptedAnswer: {
"@type": "Answer",
text: faq.answer
}
}))
}
}
BreadcrumbList schema
function breadcrumbSchema(url: URL) {
const segments = url.pathname.split("/").filter(Boolean)
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: segments.map((segment, index) => ({
"@type": "ListItem",
position: index + 1,
name: segment.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
item: `${url.origin}/${segments.slice(0, index + 1).join("/")}`
}))
}
}
Section 5: Hreflang Injection
Language/region mapping
const LOCALE_MAP: Record<string, { lang: string; region?: string }[]> = {
"/en/": [
{ lang: "en" },
{ lang: "es", region: "ES" },
{ lang: "fr", region: "FR" },
{ lang: "de", region: "DE" }
]
}
function getHreflangsForURL(url: URL): string[] {
const tags: string[] = []
const pathPrefix = Object.keys(LOCALE_MAP).find(p =>
url.pathname.startsWith(p)
)
if (!pathPrefix) return tags
const locales = LOCALE_MAP[pathPrefix]
const basePath = url.pathname.replace(pathPrefix, "")
for (const locale of locales) {
const hreflang = locale.region
? `${locale.lang}-${locale.region}`
: locale.lang
const href = `${url.origin}/${locale.lang}/${basePath}`
tags.push(`<link rel="alternate" hreflang="${hreflang}" href="${href}" />`)
}
// Add x-default
tags.push(
`<link rel="alternate" hreflang="x-default" href="${url.origin}/en/${basePath}" />`
)
return tags
}
Section 6: Bot Detection
Identify search engine crawlers
const BOT_USER_AGENTS = [
"Googlebot",
"Bingbot",
"Slurp",
"DuckDuckBot",
"GPTBot",
"ClaudeBot",
"PerplexityBot",
"Bytespider"
]
function isBot(request: Request): boolean {
const ua = request.headers.get("user-agent") || ""
return BOT_USER_AGENTS.some(bot => ua.includes(bot))
}
Serve pre-rendered content to bots
async function handleBotRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// Check for pre-rendered version in KV
const prerendered = await env.PRERENDER_CACHE.get(url.pathname)
if (prerendered) {
return new Response(prerendered, {
headers: { "content-type": "text/html; charset=utf-8" }
})
}
// Fall through to origin
return fetch(request)
}
Important: Do not block legitimate AI crawlers (GPTBot, ClaudeBot) unless you specifically want to opt out of AI training data. These crawlers also power real-time retrieval for AI search engines.
Section 7: Testing and Deployment
Local testing with Wrangler
# Start local dev server
wrangler dev --local
# Test redirect
curl -I http://localhost:8787/old-path
# Test schema injection
curl http://localhost:8787/blog/my-post | grep "application/ld+json"
# Test with bot User-Agent
curl -H "User-Agent: Googlebot" http://localhost:8787/spa-page
Validate SEO output
Before deploying to production:
- Test redirects return correct status codes (301 vs 302)
- Validate injected JSON-LD with Google's Rich Results Test
- Check hreflang tags with hreflang validator tools
- Verify bot detection serves complete HTML
Deploy to production
# Deploy to Cloudflare
wrangler deploy
# Verify on production
curl -I https://yourdomain.com/old-path
Section 8: Monitoring
Key metrics to track
- Worker execution time — Should be under 10ms for most operations
- Error rate — Monitor for unhandled exceptions in worker logs
- Redirect hit rate — Track which rules are actually being used
- Search Console crawl stats — Watch for response time changes after deployment
Logging pattern
async function logRequest(request: Request, action: string, details: string) {
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
url: request.url,
action,
details,
userAgent: request.headers.get("user-agent")
})
)
}
Search Console monitoring
After deploying edge SEO changes:
- Check the Crawl Stats report for response time increases
- Monitor the Coverage report for new crawl errors
- Review the Rich Results report for schema validation issues
- Watch for indexing anomalies in the Pages report
Common Mistakes
Redirect loops
Always check that redirect targets don't themselves redirect. A → B → A creates an infinite loop that search engines penalize.
Modifying non-HTML responses
Only run HTMLRewriter on responses with content-type: text/html. Running it on images, JSON, or CSS wastes execution time and can corrupt responses.
Over-fetching from KV
KV reads are fast but not free. Cache frequently accessed data in worker memory using the caches API or module-level variables.
Blocking AI crawlers accidentally
Bot detection logic often blocks all non-browser User-Agents. Ensure GPTBot, ClaudeBot, and PerplexityBot are allowed through if you want AI search visibility.
Frequently Asked Questions
How much does this cost on Cloudflare?
The Workers free tier includes 100,000 requests per day. The paid plan ($5/month) includes 10 million requests. For most SEO use cases, the free tier is sufficient during initial deployment.
Can I use this with non-Cloudflare CDNs?
The patterns translate to other edge platforms. AWS Lambda@Edge uses a similar request/response interception model. Fastly Compute and Vercel Edge Functions support comparable capabilities with different APIs.
Does edge HTML modification affect page speed?
Minimal impact. HTMLRewriter processes HTML in a streaming fashion, adding 1-5ms of latency. This is negligible compared to the 200-500ms saved by handling redirects at the edge instead of origin.
How do I roll back edge SEO changes?
Cloudflare Workers supports instant rollback to previous versions. Use wrangler rollback or roll back via the Cloudflare dashboard. Changes take effect globally within seconds.
Related Resources
- Edge SEO Glossary — Core edge SEO concepts
- Edge SEO Optimization Use Case — When and why to use edge SEO
- Cloudflare Workers for SEO Guide — Detailed implementation guide