Swill
← Back

Debugging Next.js on Cloudflare Workers: Three Runtime Gotchas

·4 min read·

Three bugs hit after deploying swill.fun to Cloudflare Workers — all worked fine locally but broke in production.


1. next-mdx-remote Crashes in Workers Runtime

The symptom

/now and /blog/[slug] returned 500 Internal Server Error on the deployed site. Local dev (localhost:3000) worked fine.

Why

next-mdx-remote compiles MDX at runtime using Node.js's vm module. Cloudflare Workers don't support vm — the sandbox forbids dynamic code execution entirely. The error is swallowed into a generic 500 with no stack trace in the Wrangler logs.

force-static and generateStaticParams don't help either. Even when Next.js marks a page as ○ (Static) in the build output, OpenNext still routes it through the Worker function, where the import of next-mdx-remote triggers the crash.

The fix

Compile Markdown to HTML at build time in the Velite transform, using a unified pipeline. Store the result as an HTML string. Render with dangerouslySetInnerHTML at runtime — no code execution needed.

// velite.config.ts
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
import rehypePrettyCode from "rehype-pretty-code";
import { toHtml } from "hast-util-to-html";
 
async function markdownToHtml(markdown: string): Promise<string> {
  const processor = unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeSlug)
    .use(rehypeAutolinkHeadings, { behavior: "wrap" })
    .use(rehypePrettyCode, { theme: "vesper", keepBackground: false });
  const tree = await processor.run(processor.parse(markdown));
  return toHtml(tree, { allowDangerousHtml: true });
}
 
// In the posts schema transform:
.transform(async (data) => ({
  ...data,
  html: await markdownToHtml(data.body),
}))
// app/blog/[slug]/page.tsx
<div dangerouslySetInnerHTML={{ __html: post.html }} />

All rehype plugins — syntax highlighting, slug anchors, autolink headings — still run, just at build time instead of runtime. The compiled HTML is stored in .velite/posts.json and bundled into the Worker as static data.

Note: s.mdx() from Velite has the same problem — it compiles to a JS function string that requires new Function() to execute, which is also blocked in Workers.


2. params Is a Promise in Next.js 15+

The symptom

Blog post pages returned 404. The slug was correct, generateStaticParams returned the right values, but getPostBySlug(params.slug) always returned undefined.

Why

Next.js 15 made params and searchParams async — they're now Promise objects. Accessing params.slug synchronously returns undefined because you're reading a property off a Promise, not the resolved value.

// ❌ This silently returns undefined for slug
export default function Page({ params }: { params: { slug: string } }) {
  const post = getPostBySlug(params.slug); // params.slug === undefined
}

Local dev mode sometimes tolerates this (Next.js may unwrap it for you in development), which is why it works on localhost:3000 but breaks in the production build.

The fix

// ✅ Await params before accessing properties
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = getPostBySlug(slug);
}

Same applies to generateMetadata and any other function that receives params or searchParams.


3. Build Artifacts Committed to Git

The symptom

The repository contained .open-next/ (1,695 files), .velite/ (4 files), and .wrangler/ (3 SQLite files) — all generated at build/dev time, none of which belong in version control.

Why

The default Next.js .gitignore only ignores .next/. The Cloudflare and Velite tooling don't add their own ignore rules, so the first git add . after a build sweeps everything in.

The fix

Add to .gitignore:

# cloudflare / opennext build output
.open-next/
.wrangler/

# velite generated files
.velite/

Remove already-tracked files:

git rm -r --cached .open-next/ .velite/ .wrangler/
git commit -m "chore: remove build artifacts from tracking"

.wrangler/ specifically contains Miniflare's local state — SQLite files for KV, cache, and durable objects simulation. These are ephemeral and machine-specific.


Debugging Tip: Use bun run preview

localhost:3000 (Next.js dev server) runs in Node.js with full API access — it won't reproduce any of these bugs. To catch Workers-specific failures before pushing:

bun run preview

This runs opennextjs-cloudflare build and then wrangler dev — the actual Workers runtime locally. The terminal logs show request/response pairs and any Worker-level errors. If it works here, it'll work in production.