Skip to content

Serve www.radix-ui.com from Cloudflare Workers static assets - #911

Open
moritz-petersen wants to merge 2 commits into
radix-ui:mainfrom
moritz-petersen:feature/ui-1950-migrate-radix-website-to-cloudflare
Open

moritz-petersen wants to merge 2 commits into
radix-ui:mainfrom
moritz-petersen:feature/ui-1950-migrate-radix-website-to-cloudflare

Conversation

@moritz-petersen

@moritz-petersen moritz-petersen commented Sep 9, 2026

Copy link
Copy Markdown

Part of the Vercel MigrationUI-1950 (and UI-1919 for icons.radix-ui.com, which becomes a zone redirect rule, see below).

Summary

  • Next.js static export served from Cloudflare Workers static assets (WorkOS Marketing Sites account, Worker radix-website, already created and serving this branch at radix-website.workos-sites.workers.dev). next.config.js sets output: "export"; wrangler.jsonc runs pnpm build via build.command, not_found_handling: "404-page", html_handling: "drop-trailing-slash" (Next trailingSlash: false, as on Vercel), observability, workers_dev, preview_urls, no routes (the custom domain is attached at cutover).
  • Static export is feasible with no functional loss. All dynamic routes already had generateStaticParams + dynamicParams = false; docs search is client-side (MiniSearch over public/search-index.json); og images are static files; there is no next/image, ISR, getServerSideProps or server actions. Three server-side pieces had to move:
    1. /api/markdown/[...path] route + proxy.ts (Vercel served /<page>.md and Accept: text/markdown by converting the rendered HTML at request time with rehype-remark + oxfmt). The conversion moved verbatim to scripts/html-to-markdown.ts; scripts/build-markdown.ts runs it after next build and writes a <page>.md twin next to every exported page (150 files). /…/dialog.md is a plain static asset; the output is byte-identical to production's (checked for three pages).
    2. Accept: text/markdown negotiation is kept by worker/index.ts: for the docs and blog paths it serves the .md twin when asked for Markdown (adding Vary: Accept) and otherwise passes through to env.ASSETS, so _headers, html_handling and not_found_handling still apply.
    3. /colors/custom read cookies() and searchParams in a server component. Restore now happens on the client right after hydration; the URL sync waits for that restore so shared params aren't wiped first. The appearance cookie (only used by the SSR render) is removed. Degradation: returning visitors and shared links see the default palette for one paint before their palette applies. Verified on the deployed Worker: ?accent-light=…&accent-dark=…&gray-… params apply after hydration.
  • Legacy redirects live in worker/redirects.ts, not public/_redirects. The first version of this PR used _redirects; on the deployed Worker the platform did not honor file order for overlapping dynamic rules, so /docs/colors/getting-started/usage took the broader /docs/colors/* rule and landed on /colors/docs/getting-started/usage instead of /colors/docs/overview/usage (same for the aspect-ratio and label utility paths). wrangler dev honored the order, so this only showed up on the edge. The rules are now Worker code with first-match-wins semantics, run_worker_first covers every redirect source (REDIRECT_PATHS, asserted by the test), and check-export fails the build if a _redirects file reappears, since the platform would evaluate it ahead of the Worker. 308/307 match what Next emitted; query strings are preserved. Headers → public/_headers: immutable /_next/static/*, nosniff on /*.md, X-Robots-Tag: noindex on *.workos-sites.workers.dev.
  • Tests: pnpm test (Node's test runner via tsx) pins every legacy redirect to production's answer, query-string preservation, trailing-slash tolerance, that no real page is caught, and that wrangler.jsonc covers every redirect source.
  • Environment gate: SITE_ENV is inlined by next.config.js — production only when not next dev, NEXT_PUBLIC_SITE_ENV !== "preview", and WORKERS_CI_BRANCH is unset or main. GA (utils/analytics.ts) and a robots: noindex, nofollow meta (utils/metadata.ts) hang off it; fails closed.
  • metadataBasehttps://www.radix-ui.com; the blog index og image becomes relative so it resolves the same way.
  • Pre-hydration theme switcher on /colors/custom stays a controlled component, removing Radix's controlled/uncontrolled console warning on every load.
  • wrangler dev dependency; pnpm preview / pnpm deploy / pnpm test; next start removed (doesn't apply to an export); .wrangler/ ignored. README has a Hosting section (build steps, redirects, Workers Builds settings, redirect rules, cutover checklist).

Decisions that deviate from the shared conventions (and why)

  • No image-loader.ts / NEXT_PUBLIC_IMAGE_TRANSFORMS: zero next/image usages, so no Image Transformations needed on the zone.
  • Node stays 24 (.nvmrc, engines), pnpm as the repo already uses; lockfile clean.
  • .md cache policy: platform default (max-age=0, must-revalidate + ETag) instead of the old 7-day max-age, so docs updates show up right after a deploy.
  • Small behavior differences from Vercel, all harmless: trailing-slash and .html URLs are answered by the platform with a 307 (Vercel: 308 / 404); /case-studies and /docs/colors/getting-started land directly on their targets instead of via a …/ hop; an unknown docs path with Accept: text/markdown gets the HTML 404 page (Vercel: JSON).

Verified on the deployed Worker (radix-website.workos-sites.workers.dev, production-mode build)

A 48-case probe (every legacy redirect incl. the overlapping ones, trailing slashes, .html URLs, 404s, .md twins, Accept: text/markdown / text/x-markdown / HEAD, search index, colors/custom with params) run against production and the Worker differs only in the items listed above. Also: _next/static/* immutable, .md served as text/markdown + nosniff, Vary: Accept on negotiated responses, og:image absolute, gtag present, X-Robots-Tag: noindex present on workers.dev. pnpm test (28 tests), pnpm typecheck, pnpm lint, pnpm format:check clean.

Test plan

  • pnpm install && pnpm build succeeds; out/ contains 404.html, _headers, .md twins (1919 files, 115 MB, no file over 25 MB).
  • pnpm test; wrangler dev and the deployed Worker match production on redirects, trailing slashes, 404s, Markdown negotiation.
  • /colors/custom?accent-light=…&accent-dark=… restores the palette after hydration.
  • Workers Builds: connect the repo (build command empty, deploy npx wrangler deploy, non-production npx wrangler versions upload, no build variables; Node from .nvmrc) and let main deploy.
  • Cutover per README: custom domain www.radix-ui.com, the two Redirect Rules below with placeholder records, delete Vercel projects radix-website and radix-icons.

Redirect Rules to create in the radix-ui.com zone

Rule Expression Action
Apex → www (http.host eq "radix-ui.com") Dynamic redirect concat("https://www.radix-ui.com", http.request.uri.path), preserve query string, 308
icons → icons page (http.host eq "icons.radix-ui.com") Static redirect https://www.radix-ui.com/icons, preserve query string, 308

Both need a proxied placeholder DNS record (e.g. AAAA 100::). Today Vercel answers exactly these two redirects (308, query preserved; icons.radix-ui.com/*radix-ui.com/icons).

🤖 Generated with Claude Code

Vercel is no longer a deploy target for this site. The site is a
Next.js static export (`output: "export"`) served by Cloudflare Workers
static assets in the WorkOS Marketing Sites account (wrangler.jsonc,
Worker `radix-website`).

Everything except three pieces was already static (generateStaticParams
everywhere, client-side MiniSearch, static og images, no next/image):

- The /api/markdown route and proxy.ts converted rendered pages to
  Markdown at request time (rehype-remark + oxfmt, a native binary).
  The conversion moves to scripts/html-to-markdown.ts and
  scripts/build-markdown.ts writes a `<page>.md` twin next to every
  exported page after `next build`, so `/…/dialog.md` is a static file.
  worker/index.ts, scoped with `assets.run_worker_first` to the former
  proxy matcher, keeps answering `Accept: text/markdown` with the twin.
- /colors/custom read cookies() and searchParams on the server. The
  saved and shared palette are now restored on the client after
  hydration; the appearance cookie that only informed SSR is gone.
- next.config redirects()/rewrites() are ignored by an export: redirects
  move to public/_redirects (same status codes), headers to
  public/_headers (immutable /_next/static, nosniff on .md, noindex on
  workers.dev hosts).

Build-time SITE_ENV (production only for the `main` branch on Workers
Builds; never for `next dev` or NEXT_PUBLIC_SITE_ENV=preview) gates
Google Analytics and adds robots noindex to previews, so a workers.dev
preview can't report or be indexed as the live site. metadataBase is
the canonical www host so og:image URLs resolve without a redirect.
scripts/check-export.ts fails the build if the export is missing the
files the hosting relies on. README documents build, Workers Builds
settings, the apex/icons redirect rules and the cutover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

@moritz-petersen is attempting to deploy a commit to the WorkOS Team on Vercel.

A member of the Team first needs to authorize it.

@moritz-petersen
moritz-petersen marked this pull request as draft September 9, 2026 19:13
The former redirects() from next.config.js were ported to public/_redirects,
but on the Cloudflare edge overlapping dynamic rules did not follow file
order: /docs/colors/getting-started/usage took the broader /docs/colors/*
rule and landed on /colors/docs/getting-started/usage instead of
/colors/docs/overview/usage (same for the aspect-ratio and label utilities).
wrangler dev honored the order, so this only showed on the deployed Worker.

worker/redirects.ts now holds the rules with first-match-wins semantics and
a matcher for splats and placeholders; worker/index.ts applies them before
the Markdown negotiation, and run_worker_first covers every redirect source
(REDIRECT_PATHS, asserted by the test). /docs/colors gets its own rule so it
still reaches the installation page, as Vercel did via /colors/docs/.
check-export now fails if a _redirects file reappears, since the platform
would evaluate it ahead of the Worker.

Also: the pre-hydration theme switcher on /colors/custom stays a controlled
component (value "light"), removing Radix's controlled/uncontrolled warning
on every page load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@moritz-petersen
moritz-petersen marked this pull request as ready for review September 16, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant