Edge SEO with Cloudflare Workers

Fix and optimize SEO at the CDN edge — inject tags, redirect at scale, and patch a CMS you can’t change.

📖 12 min read 🕑 Updated 2026-06-22

Edge SEO is the practice of changing what a browser or crawler receives after your origin server has produced the page, but before it reaches the client. The rewriting happens on the CDN — in your case, on a Cloudflare Worker that sits between the visitor and your site.

Think of it like a copy editor standing at the post office. The author (your CMS) has already written and sealed the letter. You can’t get them to rewrite it — maybe they’re on a legacy platform, maybe a different team owns the templates, maybe the change is urgent. So the editor opens the envelope at the last possible moment, fixes a typo on the canonical tag, stamps a redirect on a dead URL, and sends it on. The recipient never knows the letter was touched.

Because this site already runs on Cloudflare Pages, you are one Worker away from this capability. You don’t need a new vendor, a tag manager, or origin access. You need a fetch handler and HTMLRewriter, Cloudflare’s streaming HTML parser. The rest of this guide shows you when that’s the right move, the patterns worth knowing, a complete worked example, and the ways it can quietly go wrong.

When to use it

Edge SEO is a precision tool, not a default. Reach for it when the normal path — editing templates at the origin — is blocked or too slow.

SituationWhy the edge wins
Legacy or locked CMSThe platform renders <head> and you can’t touch the template. The edge rewrites the output regardless of the backend.
SaaS / hosted platformShopify, a marketing site builder, a headless storefront you don’t control end-to-end. No server to SSH into — but a Worker can sit in front.
Site-wide consistencyYou need the same canonical or hreflang logic on 50,000 URLs. One Worker enforces it; you don’t trust 50,000 templates to agree.
Urgent hotfixA bad deploy shipped noindex to production. A dev sprint is two weeks out. A Worker fix is live in 60 seconds and reversible.
A/B SEO experimentsTest a title-tag pattern on 50% of traffic to a section without forking templates.

🧑‍💻 Developer view: the edge is the right layer when the change is cross-cutting (applies to many pages by rule) or time-critical (origin deploy is the bottleneck). If a change is genuinely page-specific and you control the template, do it at the origin — that’s where it belongs and where the next engineer will look for it.

The flip side: every edge rule is a piece of logic that lives outside your repo’s normal review flow. Treat it as production infrastructure, not a sticky note. We’ll return to this in Caveats.

Common patterns

These are the workhorses. Each is a few lines of HTMLRewriter, which streams the response and lets you attach handlers to CSS selectors — so you never buffer the whole document in memory.

Inject or fix canonical and hreflang

The most common edge SEO job. A CMS emits a canonical pointing at the wrong host (http, a staging domain, a trailing-slash mismatch), or omits hreflang entirely on a bilingual site.

class CanonicalFixer {
  constructor(url) {
    this.canonical = `https://example.com${new URL(url).pathname}`;
    this.found = false;
  }
  element(el) {
    // Rewrite an existing canonical to the correct absolute URL.
    el.setAttribute("href", this.canonical);
    this.found = true;
  }
}

You pair that with an end handler on <head> to inject the tag when it was missing — shown in full in the worked example below.

Redirect at scale (301 / 302)

Migrations leave thousands of dead URLs. Instead of bloating origin config or _redirects, drive them from a Worker backed by a map (or KV for large sets).

const REDIRECTS = {
  "/old-pricing": "/pricing",
  "/blog/2019/seo-tips": "/guides/seo-basics",
};

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const target = REDIRECTS[url.pathname];
    if (target) {
      return Response.redirect(`${url.origin}${target}`, 301);
    }
    return fetch(request); // pass through to origin
  },
};

💡 Tip: for tens of thousands of redirects, move the map into Workers KV and look it up by key. KV reads are cached at the edge, so the lookup stays fast and you avoid shipping a giant object on every cold start.

Patch meta tags and structured data

Add a missing meta description, strip a stray noindex, or inject JSON-LD that the CMS can’t produce. Injecting structured data is just appending a <script type="application/ld+json"> to <head>:

class JsonLdInjector {
  constructor(data) {
    this.json = JSON.stringify(data);
  }
  element(head) {
    head.append(
      `<script type="application/ld+json">${this.json}</script>`,
      { html: true }
    );
  }
}

SEO A/B experiments

Split traffic deterministically (by a hashed cookie or path bucket), serve variant title/description to one bucket, and measure the difference in impressions and CTR in Search Console. Keep the content identical for users — you’re testing the SERP-facing snippet, not cloaking different pages.

Crawler-aware handling by User-Agent

Detect Googlebot or Bingbot and apply rules — for example, serving a fully rendered fallback, or adding caching headers tuned for crawlers. This is legitimate only when the crawler and the user receive the same meaningful content. Diverging the content itself is cloaking; see Caveats.

const ua = request.headers.get("user-agent") || "";
const isBot = /googlebot|bingbot|duckduckbot/i.test(ua);

A worked example

Here’s a complete, production-shaped Worker that does the single most valuable thing for a bilingual site like this one: it guarantees a correct canonical and a matching hreflang set on every page — fixing the tag if present, injecting it if absent. It’s idempotent (running it twice changes nothing) and it passes everything else straight through.

const SITE = "https://example.com";

// Map a path to its language alternates. Adapt to your routing.
function alternatesFor(pathname) {
  const en = pathname.startsWith("/zh/")
    ? pathname.replace(/^\/zh\//, "/en/")
    : pathname;
  const zh = pathname.startsWith("/en/")
    ? pathname.replace(/^\/en\//, "/zh/")
    : pathname;
  return [
    { lang: "en", href: `${SITE}${en}` },
    { lang: "zh", href: `${SITE}${zh}` },
    { lang: "x-default", href: `${SITE}${en}` },
  ];
}

// Rewrite an existing canonical to the canonical absolute URL.
class CanonicalRewriter {
  constructor(canonical) {
    this.canonical = canonical;
    this.seen = false;
  }
  element(el) {
    el.setAttribute("href", this.canonical);
    this.seen = true;
  }
}

// On </head>, inject anything the page was missing.
class HeadCloser {
  constructor(canonical, alternates, rewriter) {
    this.canonical = canonical;
    this.alternates = alternates;
    this.rewriter = rewriter;
  }
  element(head) {
    if (!this.rewriter.seen) {
      head.append(
        `<link rel="canonical" href="${this.canonical}">`,
        { html: true }
      );
    }
    // Always (re)assert hreflang. We strip old ones first (below),
    // so appending here yields exactly one correct set.
    for (const alt of this.alternates) {
      head.append(
        `<link rel="alternate" hreflang="${alt.lang}" href="${alt.href}">`,
        { html: true }
      );
    }
  }
}

export default {
  async fetch(request) {
    const response = await fetch(request);

    // Only rewrite HTML; leave assets, JSON, redirects untouched.
    const type = response.headers.get("content-type") || "";
    if (!type.includes("text/html")) return response;

    const url = new URL(request.url);
    const canonical = `${SITE}${url.pathname}`;
    const alternates = alternatesFor(url.pathname);
    const rewriter = new CanonicalRewriter(canonical);

    return new HTMLRewriter()
      // Remove any stale hreflang so we don't duplicate them.
      .on('link[rel="alternate"][hreflang]', { element: (el) => el.remove() })
      .on('link[rel="canonical"]', rewriter)
      .on("head", new HeadCloser(canonical, alternates, rewriter))
      .transform(response);
  },
};

What makes this safe to ship:

  • It scopes by content type. Non-HTML responses pass through verbatim — you never corrupt an image or a JSON API.
  • It’s idempotent. Stale hreflang tags are stripped, then exactly one correct set is appended. Re-running produces the identical result.
  • It streams. HTMLRewriter processes the response as it flows; there’s no full-document buffer and almost no latency cost.
  • It fixes-or-injects canonical in one pass: the CanonicalRewriter corrects an existing tag, and HeadCloser adds one only if none was seen.

Deploy it with Wrangler and route it to your zone:

npx wrangler deploy
# Then bind a route in wrangler.toml, e.g.
# routes = [{ pattern = "example.com/*", zone_name = "example.com" }]

🧑‍💻 Developer view: keep the SITE constant and the alternatesFor mapping in one place and unit-test the mapping function. The lang-routing logic is where edge SEO bugs hide — a regex that mishandles /en/en/ or the homepage will silently emit wrong alternates across the whole site.

Caveats

Edge SEO is powerful precisely because it’s invisible — which is also how it bites you. Internalize these before you route a Worker over production traffic.

  • Make every rewrite idempotent. If the origin later starts emitting the tag you’re injecting, your Worker must not produce duplicates. The pattern above strips-then-appends for exactly this reason. Duplicate canonicals are worse than none.
  • Don’t fight the origin. The edge and the CMS must not assert different canonicals or conflicting robots directives. When a future template change collides with an edge rule, Google sees contradictory signals and you get nondeterministic indexing. Document every edge rule so the origin team knows it exists.
  • Mind the cache. Cloudflare may cache the rewritten HTML. If your rewrite depends on the request (User-Agent, cookie, geo), you must vary the cache key accordingly or you’ll serve one variant to everyone. For request-dependent logic, set Cache-Control / Vary deliberately, or bypass the cache for those routes.
  • Watch your latency and error budget. A Worker that throws on an edge case can blank a page for crawlers. Wrap risky logic in try/catch and fail open — return the original response untouched rather than a 500.
  • Monitor it like origin code. Wire up wrangler tail, log rewrite counts, and watch Search Console’s Pages and Removals reports after every change. An edge bug shows up as an indexing anomaly, not a stack trace.
  • Never use it for cloaking. Serving crawlers different content than users — keyword-stuffed text for Googlebot, a clean page for humans — violates Google’s spam policies and risks a manual action. UA-based handling is fine for delivery and caching; it is not a license to show two different pages.

⚠️ Caution: the cardinal sin of edge SEO is the silent divergence between what users see and what crawlers see. Verify with Search Console’s URL Inspection (“View crawled page”) that the rendered HTML the bot got matches what a normal browser gets. If they differ in substance, you’re cloaking — intentionally or not.

Where this fits

Edge SEO is a tactic inside the broader build layer of your stack — it’s how you enforce technical SEO when the template can’t. For the foundations it sits on top of (rendering, canonical strategy, internal linking), see the Build layer. And once your Worker is shaping responses, make sure your crawl directives agree with it: validate robots.txt and your sitemap with the robots & sitemap tool so the edge, the origin, and your crawl rules all tell Google the same story.

Key takeaways

  • ✅ Use the edge to fix SEO after the origin and before the client — ideal for locked CMSes, hosted platforms, site-wide rules, and urgent hotfixes.
  • HTMLRewriter streams the response, so injecting canonical, hreflang, meta, or JSON-LD costs almost no latency and no full-document buffer.
  • ✅ Make every rewrite idempotent (strip-then-append) so it never duplicates tags the origin may later emit.
  • ✅ Scope rewrites to text/html, wrap logic in try/catch, and fail open to the original response.
  • ✅ Treat Workers as production code: version it, document each rule so it can’t collide with the origin, and monitor Search Console after every change.
  • ✅ Never diverge content between users and crawlers — UA handling is for delivery, not cloaking.