RSSAmplifier

xylem | Gordon Beeming · Mar 22, 2026

Next.js + TinaCMS on GitHub Pages behind a Cloudflare Worker

0
Sign in to vote or save

Gordon Beeming · xylem | Gordon Beeming

I recently moved this blog to a setup where Next.js builds a static export (hosted on GitHub Pages), TinaCMS provides a Git-backed CMS admin panel, and a Cloudflare Worker sits in front handling the custom domain, security headers, and CSP.

It works well now, but getting here involved a few gotchas that weren't immediately obvious. Here's what I hit and how I fixed each one.

#The architecture

The request flow looks like this:

The Cloudflare Worker runs at the edge and handles:

  • Path rewriting — prepends the repo name (/xylem) to paths before forwarding to GitHub Pages
  • Security headers — adds X-Frame-Options, X-Content-Type-Options, Referrer-Policy, etc.
  • CSP with nonce injection — uses HTMLRewriter to inject nonces into <script> and <style> tags
  • Redirects — handles URL redirects that can't work at the static site level

On the Next.js side, the config is straightforward:

And TinaCMS builds its admin panel into public/admin/ as static files:

Simple enough. Here's where it falls apart.

#Gotcha 1: static export means no server features

When you set output: "export", Next.js generates a fully static site — no Node.js server, no API routes, no middleware, no rewrites, no redirects. If you have any API routes (like an OG image generator at /api/og), the build will fail:

In CI, I handle this by removing API routes before building:

This means pnpm dev still works locally with API routes (it runs in dev mode, not export mode), but the production build knows to skip them.

For anything the API routes were doing, you need a static alternative. In my case, the /api/og route generated Open Graph images on-the-fly using next/og. I replaced it with a build-time script (scripts/generate-og-images.mjs) that uses satori and @resvg/resvg-js to pre-render every OG image as a PNG into public/og/. Same visual output, but generated at build time instead of on each request.

The other thing that catches people out: redirects defined in next.config.ts don't work with static export. If you have URL redirect rules (I had 146 of them for old blog post URLs), they need to move to the Cloudflare Worker.

#Gotcha 2: GitHub Pages redirect leaks

This one is subtle and took a while to track down.

GitHub Pages likes to normalize URLs — for example, it redirects /admin to /admin/ (trailing slash). It does this by returning a 301 response with a Location header. The problem? That Location header points to the real GitHub Pages URL:

If your Cloudflare Worker just passes this response through to the browser, the user gets redirected from gordonbeeming.com/admin to gordonbeeming.github.io/xylem/admin/ — completely bypassing your worker and exposing the underlying hosting.

The fix is to rewrite Location headers in the worker before returning the response:

Now the browser stays on gordonbeeming.com and never sees the github.io URL.

#Gotcha 3: TinaCMS admin on a proxied domain

TinaCMS Cloud needs to know the URL of your site so the login flow works correctly. If the Site URL in your TinaCMS Cloud dashboard doesn't match the URL in the browser, the login window won't close after authentication — it just hangs.

For this setup, the Site URL must be set to your custom domain (https://gordonbeeming.com), not the GitHub Pages URL.

There's also a build.basePath option in the TinaCMS config that you might think you need since the site lives at /xylem/ on GitHub Pages. Don't set it when using a proxy worker. The worker already translates paths:

  • Browser requests gordonbeeming.com/admin/assets/main.js
  • Worker forwards to gordonbeeming.github.io/xylem/admin/assets/main.js

If you set basePath: "xylem", TinaCMS would generate asset paths like /xylem/admin/assets/main.js. Through the worker, that becomes /xylem/xylem/admin/assets/main.js — double prefix, broken assets.

Without the Location header fix from Gotcha 2, visiting /admin redirects to github.io where TinaCMS shows "Failed loading TinaCMS assets" because everything is misaligned.

#Gotcha 4: redirects at the edge

Since Next.js static export can't handle redirects, they need to live in the Cloudflare Worker. I migrated all 146 redirect rules into a simple map:

The worker checks the request path against this map before proxying to GitHub Pages. For wildcard patterns (like /:path*.mdx), I use a simple regex matcher:

One thing to watch out for: preserve query strings on redirects. If someone shares a link with UTM parameters, you don't want to drop them:

#The full picture

Here's how the configuration breaks down across each layer:

LayerConfigKey Setting
Next.jsnext.config.tsoutput: "export", unoptimized: true for images
TinaCMStina/config.tsoutputFolder: "admin", no basePath
TinaCMS CloudDashboardSite URL = https://yourdomain.com
GitHub Actionsdeploy.ymlRemove API routes before build, deploy to Pages
Cloudflare Workersrc/index.jsPath rewriting, Location header rewriting, redirects

#This is a living post

I'll update this if I hit more gotchas. The post history is in the commit log if you're curious what changed.

If you're running a similar setup and hit something I haven't covered, reach out — I'd be happy to add it here.

Repos:

Read the original on gordonbeeming.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.