Fumadocs is one of the best documentation frameworks in the React ecosystem — built on Next.js with MDX support, full-text search, and a polished UI out of the box. But deploying a Next.js app to Cloudflare has historically been painful.
Enter Vinext — Cloudflare's Vite-based reimplementation of the Next.js API surface. It lets you run your Next.js App Router project on Cloudflare Workers without rewriting anything.
Sounds simple. In practice, there are a few sharp edges. This guide walks through every step and pitfall we hit deploying a real Fumadocs site to Cloudflare Workers.
Prerequisites
- A working Fumadocs project (App Router)
- Bun or Node.js installed
- A Cloudflare account
1. Install Dependencies
bun add -d vinext vite @vitejs/plugin-rsc react-server-dom-webpack @cloudflare/vite-plugin wrangler
Add "type": "module" to your package.json:
{
"type": "module"
}
2. Create vite.config.ts
Fumadocs uses MDX, which needs to be transformed before the RSC plugin processes it. The key is placing fumadocs-mdx/vite before vinext() in the plugin order:
import { cloudflare } from "@cloudflare/vite-plugin";
import mdxPlugin from "fumadocs-mdx/vite";
import vinext from "vinext";
import { defineConfig } from "vite";
import * as sourceConfig from "./source.config";
export default defineConfig({
plugins: [
await mdxPlugin(sourceConfig),
vinext(),
cloudflare({
viteEnvironment: {
name: "rsc",
childEnvironments: ["ssr"],
},
}),
],
resolve: {
dedupe: [
"fumadocs-core",
"fumadocs-ui",
"react",
"react-dom",
],
},
ssr: {
noExternal: [
"fumadocs-core",
"fumadocs-ui",
],
},
});
Why resolve.dedupe and ssr.noExternal? Vite's multi-environment build (RSC + SSR + Client) can create multiple copies of fumadocs-core and react. Deduplication ensures React context works across boundaries.
3. Update Scripts
{
"scripts": {
"dev": "vinext dev",
"build": "vinext build",
"start": "vinext start",
"deploy": "vinext deploy"
}
}
At this point, bun run dev should start your Fumadocs site on Vite. But you'll likely hit a blank page.
4. Fix the React Context Boundary Problem
This is the biggest gotcha. Fumadocs components like DocsLayout and DocsPage use React hooks internally but don't have 'use client' directives — they rely on Next.js's bundler to pull them into the client boundary automatically.
Vinext's RSC plugin is stricter. If a server component renders DocsLayout, the context chain breaks. You'll see errors like:
Uncaught Error: You need to wrap your application inside FrameworkProvider
or:
Cannot destructure property 'isNavTransparent' of 'use(...)' as it is null
The fix: Create explicit 'use client' wrapper components that co-locate the context providers and consumers in the same client tree.
app/docs-layout.client.tsx
'use client';
import { RootProvider } from 'fumadocs-ui/provider/next';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import type { Root } from 'fumadocs-core/page-tree';
export function DocsLayoutClient({
tree,
options,
children,
}: {
tree: Root;
options: BaseLayoutProps;
children: React.ReactNode;
}) {
return (
<RootProvider>
<DocsLayout tree={tree} {...options}>
{children}
</DocsLayout>
</RootProvider>
);
}
app/docs-page.client.tsx
'use client';
import {
DocsPage,
DocsBody,
DocsTitle,
DocsDescription,
} from 'fumadocs-ui/layouts/docs/page';
import type { TOCItemType } from 'fumadocs-core/toc';
export function DocsPageClient({
toc,
full,
title,
description,
children,
}: {
toc: TOCItemType[];
full?: boolean;
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<DocsPage toc={toc} full={full}>
<DocsTitle>{title}</DocsTitle>
<DocsDescription>{description}</DocsDescription>
<DocsBody>{children}</DocsBody>
</DocsPage>
);
}
Update app/layout.tsx
import { source } from '@/lib/source';
import { baseOptions } from '@/lib/layout.shared';
import { DocsLayoutClient } from './docs-layout.client';
import './global.css';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<DocsLayoutClient
tree={source.getPageTree()}
options={baseOptions}
>
{children}
</DocsLayoutClient>
</body>
</html>
);
}
Update app/[[...slug]]/page.tsx
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/mdx-components';
import { DocsPageClient } from '../docs-page.client';
export default async function Page({ params }) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPageClient
toc={page.data.toc}
full={page.data.full}
title={page.data.title}
description={page.data.description}
>
<MDX components={getMDXComponents({})} />
</DocsPageClient>
);
}
5. Fix OG Image Generation
If you're using @takumi-rs/image-response (Fumadocs' default for fumadocs-ui/og/takumi), it will crash on Cloudflare Workers — it's a native Rust/NAPI module that uses createRequire(import.meta.url) to load .node binaries.
Even the standard fumadocs-ui/og (which uses next/og / @vercel/og) has a subtler problem: it eagerly fetches a fallback font file via import.meta.url at module initialization time, which fails in the Workers environment.
The fix: Use a dynamic import so the OG code only loads when the route is hit, not at Worker startup:
// app/og/[...slug]/route.tsx
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
export async function GET(_req: Request, { params }) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
// Dynamic import avoids @vercel/og eager font fetch at module init
const { ImageResponse } = await import('next/og');
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
color: 'white',
padding: '4rem',
backgroundColor: '#0c0c0c',
}}
>
<p style={{ fontSize: '82px', fontWeight: 800, margin: 0 }}>
{page.data.title}
</p>
<p style={{ fontSize: '52px', color: 'rgba(240,240,240,0.8)', margin: 0 }}>
{page.data.description}
</p>
</div>
),
{ width: 1200, height: 630 },
);
}
And remove the native package:
bun remove @takumi-rs/image-response
6. Create the Worker Entry
Create worker/index.ts. The critical detail: only route image optimization requests to handleImageOptimization. All other requests go to the vinext handler. If you pass everything through handleImageOptimization, you'll get 400 Bad Request on every page.
import {
handleImageOptimization,
DEFAULT_DEVICE_SIZES,
DEFAULT_IMAGE_SIZES,
} from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: {
format: string;
quality: number;
}): Promise<{ response(): Response }>;
};
};
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Only handle image optimization requests
if (url.pathname === "/_vinext/image") {
const allowedWidths = [
...DEFAULT_DEVICE_SIZES,
...DEFAULT_IMAGE_SIZES,
];
return handleImageOptimization(
request,
{
fetchAsset: (path) =>
env.ASSETS.fetch(
new Request(new URL(path, request.url))
),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body)
.transform(width > 0 ? { width } : {})
.output({ format, quality });
return result.response();
},
},
allowedWidths
);
}
// Delegate everything else to vinext
return handler.fetch(request);
},
};
7. Create wrangler.jsonc
As of vinext 0.0.18 and wrangler 4.69.0, the auto-generated wrangler config is missing the required assets.directory property (vinext#219). Create one manually:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "your-docs-site",
"compatibility_date": "2025-12-01",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker/index.ts",
"assets": {
"directory": "./dist/client",
"not_found_handling": "none",
"binding": "ASSETS"
},
"images": {
"binding": "IMAGES"
}
}
8. Build and Test Locally
bun run build
npx wrangler dev
Open http://localhost:8787 — you should see your Fumadocs site running on the Workers runtime.
9. Deploy
bun run deploy
Or set up CI/CD with Cloudflare auto-detecting pushes to your main branch.
Recap: The Three Pitfalls
| Problem | Symptom | Fix |
|---|---|---|
| RSC context boundaries | Blank page, FrameworkProvider errors |
Wrap Fumadocs layout/page components in explicit 'use client' wrappers |
| Native modules |
createRequire / Invalid URL string at deploy |
Remove @takumi-rs/image-response, use dynamic import('next/og')
|
| Worker routing |
400 Bad Request on all pages |
Only route /_vinext/image through handleImageOptimization, delegate rest to handler.fetch()
|
Final Project Structure
your-docs-site/
├── app/
│ ├── layout.tsx # Uses DocsLayoutClient
│ ├── docs-layout.client.tsx # 'use client' wrapper
│ ├── docs-page.client.tsx # 'use client' wrapper
│ ├── [[...slug]]/page.tsx # Uses DocsPageClient
│ └── og/[...slug]/route.tsx # Dynamic import for next/og
├── worker/
│ └── index.ts # Cloudflare Worker entry
├── vite.config.ts # fumadocs-mdx/vite + vinext + cloudflare
├── wrangler.jsonc # Manual config with assets.directory
├── source.config.ts
└── package.json
Vinext is still early (0.0.18 at time of writing), but it already handles the full Fumadocs feature set — MDX, sidebar navigation, search, table of contents, and OG images — all running on Cloudflare Workers at the edge.
Top comments (0)