DEV Community

DeVoresyah ArEst
DeVoresyah ArEst

Posted on

Deploy Fumadocs to Cloudflare Workers with Vinext

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
Enter fullscreen mode Exit fullscreen mode

Add "type": "module" to your package.json:

{
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

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",
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

Cannot destructure property 'isNavTransparent' of 'use(...)' as it is null
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 },
  );
}
Enter fullscreen mode Exit fullscreen mode

And remove the native package:

bun remove @takumi-rs/image-response
Enter fullscreen mode Exit fullscreen mode

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);
  },
};
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Build and Test Locally

bun run build
npx wrangler dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8787 — you should see your Fumadocs site running on the Workers runtime.

9. Deploy

bun run deploy
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)