Redirections
Integration Guides

Cloudflare Workers

Resolve deployed Redirections.app rules from an existing Cloudflare Worker without overriding successful application routes.

Overview

This integration checks Redirections.app only after your application returns a 404. Existing pages keep their normal response, while deployed redirect rules can recover legacy URLs at the edge.

The example is fail-open: a lookup miss, timeout, invalid response, or API error returns the original application 404.

Configure Wrangler

Add the project ID as a non-secret variable in wrangler.jsonc:

wrangler.jsonc
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "your-worker",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-10",
  "vars": {
    "REDIRECTIONS_PROJECT_ID": "YOUR_PROJECT_ID",
  },
}

Store the API key as a Worker secret:

pnpm exec wrangler secret put REDIRECTIONS_API_KEY

For local type generation, place a non-production placeholder in the ignored .dev.vars file, then regenerate bindings:

.dev.vars
REDIRECTIONS_API_KEY=local-development-placeholder
pnpm exec wrangler types

Never put the API key in vars, source code, or a VITE_/PUBLIC_ variable. Those values can be exposed to clients or version control.

Add the lookup wrapper

Wrap your existing application handler. Replace app.fetch(...) with your framework or origin handler.

src/index.ts
import app from './app'

type LookupResult = {
  destination: string
  statusCode: 301 | 302
}

async function withRedirections(
  request: Request,
  env: Cloudflare.Env,
  next: () => Promise<Response>,
): Promise<Response> {
  const applicationResponse = await next()
  const source = new URL(request.url)
  const acceptsHtml =
    !request.headers.get('accept') ||
    request.headers.get('accept')!.includes('text/html') ||
    request.headers.get('accept')!.includes('*/*')

  if (
    applicationResponse.status !== 404 ||
    !['GET', 'HEAD'].includes(request.method) ||
    !acceptsHtml ||
    /\.[a-z0-9]{1,10}$/i.test(source.pathname)
  ) {
    return applicationResponse
  }

  const lookup = new URL('https://api.3xx.app/v1/lookup')
  lookup.searchParams.set('project', env.REDIRECTIONS_PROJECT_ID)
  lookup.searchParams.set('path', source.pathname)

  try {
    const response = await fetch(lookup, {
      headers: { 'X-API-Key': env.REDIRECTIONS_API_KEY },
      signal: AbortSignal.timeout(250),
    })

    if (response.status !== 200) return applicationResponse

    const data: unknown = await response.json()
    if (!isLookupResult(data)) return applicationResponse

    const destination = new URL(data.destination, source)
    if (!['http:', 'https:'].includes(destination.protocol)) {
      return applicationResponse
    }
    if (!destination.search && source.search) destination.search = source.search
    if (destination.href === source.href) return applicationResponse

    return new Response(null, {
      status: data.statusCode,
      headers: {
        Location: destination.href,
        'Cache-Control':
          data.statusCode === 301 ? 'public, max-age=86400' : 'no-store',
      },
    })
  } catch {
    return applicationResponse
  }
}

function isLookupResult(value: unknown): value is LookupResult {
  if (!value || typeof value !== 'object') return false
  const candidate = value as Record<string, unknown>
  return (
    typeof candidate.destination === 'string' &&
    (candidate.statusCode === 301 || candidate.statusCode === 302)
  )
}

export default {
  fetch(request, env, ctx) {
    return withRedirections(request, env, () => app.fetch(request, env, ctx))
  },
} satisfies ExportedHandler<Cloudflare.Env>

The lookup sends only pathname. Incoming query parameters are copied to the destination only when the rule does not define its own query string.

Verify the deployment

Deploy a test rule, then check both a hit and a miss:

curl -I https://www.example.com/legacy-path
curl -I https://www.example.com/intentionally-missing

The hit should return the rule's 301 or 302 response. The miss should retain your application's original 404.

Operational guidance

  • Exclude static assets and non-document requests to protect lookup quota.
  • Keep the timeout short and preserve the original response on failure.
  • Point legacy rules directly to final 200 URLs to avoid redirect chains.
  • Use separate projects and API keys for staging and production.
  • Run wrangler types --check in CI after changing bindings or variables.

Cloudflare service bindings work only between Workers in the same Cloudflare account. Standard Redirections.app customers should use the authenticated https://api.3xx.app endpoint shown above.