Upgrade to Fumapress 2
Move a Fumapress 1.x site to version 2.
Fumapress 2 replaces defineConfig() with createPress(). By default, the render mode of each page now matches its content source, so one site can have both prerendered and dynamic pages. Do the steps in order, then open the sections for the features your site uses.
Coming from 0.x, follow Upgrade to Fumapress 1 first.
Upgrade Steps
Update the Packages
Fumapress 2 needs Node.js 24 or later. Update Fumapress, Fumadocs, React and Vite together, and any @fumapress/* plugins that you use.
npm i fumapress@latest react@latest react-dom@latest
npm i fumadocs-core@latest fumadocs-mdx@latest
npm i fumadocs-ui@npm:@fumadocs/base-ui@latest
npm i vite@latest -DFumapress 2 needs the Base UI build of Fumadocs UI, which the third line installs as fumadocs-ui. Coming from the Radix UI build, your own code only changes where it passes Radix props like asChild to Fumadocs UI components.
Fumapress 2 installs @fumapress/core, and some plugins moved to @fumapress/*, like @fumapress/blog. If your package manager holds back new releases, like with the minimumReleaseAge setting of pnpm, exclude @fumapress/* from it.
If your package.json lists react-server-dom-webpack, uninstall it. A copy that you list replaces the one that matches the React version of Fumapress.
Rewrite the Config
content and i18n go to createPress(). Plugins go to .plugins(), called once. Everything else goes to .config().
The fields of loaderOptions also go to createPress(), without the loaderOptions wrapper. Rename plugins to loaderPlugins.
import { defineConfig } from "fumapress";
import { fumadocsMdx } from "fumapress/adapters/mdx";
import { createPress } from "fumapress";
export default defineConfig({
content: docs.toFumadocsSource(),
loaderOptions: { plugins: [lucideIconsPlugin()] },
site: { name: "My Docs" },
})
.adapters(fumadocsMdx())
.plugins(linkValidationPlugin());
export const app = createPress(docs.toFumadocsSource(), {
loaderPlugins: [lucideIconsPlugin()],
}).plugins(linkValidationPlugin());
export default app.config({ site: { name: "My Docs" } });Delete the .adapters(fumadocsMdx()) call. MDX pages now render without it.
Update the Schema Import
import { metaSchema, pageSchema } from "fumapress/adapters/mdx/schema";
import { metaSchema, pageSchema } from "fumapress/schema";Run the Site
npm run devIf you didn't customize your site, you're done. To find the other changes, search your project for imports from fumapress/ and for the options you set, then open the matching sections below.
Site Options
Open the options your site sets.
Move your components into a module, then pass it to press(). Relative links are resolved before your a component receives them, so createRelativeLink() is removed.
import defaultMdxComponents from "fumadocs-ui/mdx";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import type { MDXComponents } from "mdx/types";
export default {
...defaultMdxComponents,
Tab,
Tabs,
} satisfies MDXComponents;The factories of fumapress/layouts/* are removed. Pass the layout and its props to renderDefault() in ui.renderPage. If you call createDocsLayoutPage() without options, delete it: the Docs Layout is the default.
import { defineConfig } from "fumapress";
import { createNotebookLayoutPage } from "fumapress/layouts/notebook";
const Notebook = createNotebookLayoutPage<typeof config.$context>({
treeRoot: "docs",
render: () => ({ pageProps: { full: true } }),
});
const config = defineConfig({
content,
renderPage: (props) => <Notebook {...props} />,
});
export default config;layoutProps and pageProps of render() keep their names. For body, lastModified, renderBody and renderPageActions, write the layout yourself from props.body, props.pageData and props.slots. See Custom Layouts.
props.type is the key of the content source, docs here. With a single source passed without a key, like createPress(docs.toFumadocsSource()), props.type is undefined on content pages.
On custom pages, replace createHomeLayout() with HomeLayout and the props it takes from ctx.getLayoutProps({ lang }). HomeLayout has no content container, so wrap your content to keep the width and padding of createHomeLayout():
<HomeLayout {...home}>
<div className="flex flex-col w-full max-w-[1400px] flex-1 px-4 py-6 mx-auto">{children}</div>
</HomeLayout>home.links has the links of ui.layoutProps and plugins. A links prop replaces them, so add the links of this layout in front of them, like links={[...homeLinks, ...home.links]}.
export default app.config({
defaultLayoutProps: { links: [{ url: "/blog", text: "Blog" }] },
ui: {
layoutProps: { links: [{ url: "/blog", text: "Blog" }] },
},
});To read these props elsewhere, use ctx.getLayoutProps({ lang }) in place of ctx.defaultLayoutProps({ lang }).
Props of the root provider move from createRootLayout() to ui.providerProps.
const Root = createRootLayout({
providerProps: { theme: { defaultTheme: "dark" } },
});
defineConfig({ content, renderRoot: (props) => <Root {...props} /> });
createPress(content).config({
ui: { providerProps: { theme: { defaultTheme: "dark" } } },
});A custom ui.renderRoot also receives head, with the charset, the viewport and the styles of your site. Render it in <head>, or the page has no CSS.
Delete renderNotFound, and add the page as a file:
export default function NotFound() {
return <main>This page doesn't exist.</main>;
}meta returned JSX. head takes a list of tags, or a function that receives page.
export default app.config({
meta: {
root: () => <link rel="icon" href="/favicon.ico" />,
page(page) {
return <meta name="robots" content="noindex" />;
},
},
head({ page }) {
if (!page) return [{ tag: "link", attrs: { rel: "icon", href: "/favicon.ico" } }];
return [{ tag: "meta", attrs: { name: "robots", content: "noindex" } }];
},
});For tags that don't depend on the page, pass a plain list. attrs take HTML attribute names, like crossorigin and http-equiv, not the React props. See Head Tags.
Pass i18n to createPress(), even when translations already had it, and move translations under ui.
fumapress/i18n is removed, so delete .extend(fumapressTranslations()). Plugin and UI strings are added for you.
const i18n = defineI18n({ languages: ["en", "zh"], defaultLanguage: "en" });
export default defineConfig({
content,
translations: i18n.translations().preset("zh", zhTW()),
});
export const app = createPress(content, { i18n });
export default app.config({
ui: { translations: i18n.translations().preset("zh", zhTW()) },
});mode moves to createPress(), with new values. The default, auto, prerenders static sources like Fumadocs MDX and renders dynamic sources on demand. Search and other generated files follow the content.
defineConfig({ content, mode: "dynamic" });
createPress(content, { mode: "force-dynamic" });mode: "dynamic"becomesforce-dynamic. Keep it if middleware has to run for your pages, like for authentication.mode: "static"becomesforce-static, which prerenders a dynamic source at build time.- Otherwise, delete
mode.
mode no longer affects custom pages and API routes. Pages are prerendered and API routes render
on demand unless getConfig() says otherwise.
A default plugin that you also list in .plugins() is added twice, with or without options, and Fumapress stops with an error that names it. Pass its options to recommendedPreset() instead:
import { createPress, recommendedPreset } from "fumapress";
createPress(content).plugins(rssPlugin({ limit: 50 }));
createPress(content).config({
preset: recommendedPreset({ rss: { limit: 50 } }),
});To replace a default plugin, like FlexSearch with Orama, set it to false and list yours. See Default Plugins.
preset: false becomes preset: []. Delete preset: "recommended", since it is the default.
A custom dialog moves to ui.providerProps, and receives the options of the search plugin as props. See Custom Search Dialog.
createPress(content).config({
ui: { providerProps: { search: { SearchDialog } } },
});The exported search index is now at /api/search.json. On a dynamic source, search still runs at /api/search.
The options of FlexSearch go to recommendedPreset({ search }). To keep Orama, set search: false and list oramaSearchPlugin(). See Search Engines.
A site deployed with a server used to search on the server. Now static sources send the index to the browser. On a large site, keep search on the server with Server-Side Search.
Types and helpers are read through app, the result of .plugins():
import type { RouteConfig, RouteProps } from "fumapress";
import config from "../../../press.config";
const ctx = config.utils().getPressContext();
import { getPressContext, type WakuRouteConfig, type WakuRouteProps } from "fumapress";
import type { app } from "../../../press.config";
const ctx = getPressContext<typeof app>();
export function getConfig() {
return {
render: "static",
takumiOptions: { title: "Showcase" },
data: { title: "Showcase", ogImage: { title: "Showcase" } },
} satisfies RouteConfig;
} satisfies WakuRouteConfig;
}typeof config.$context is now typeof app. Rename AppContext<...> to PressContext<typeof app>.
data is the page data of the page. Fumapress renders its title and description in <head>, so replace a <title> that your page renders with data.title.
Add the dispose line, so that old plugins stop on hot reload. Pin waku to the version that Fumapress uses, 1.0.0-rc.4.
const router = await createRouter(press);
import.meta.hot?.dispose(router.dispose);Plugins
Open the plugins your site uses.
The plugin moved to its own package, and source names a key of your content.
npm i @fumapress/blog@import "fumapress/css/preset.css";
@import "@fumapress/blog/css/preset.css";import { blogPlugin } from "fumapress/plugins/blog";
import { blogPageSchema, blogMetaSchema } from "fumapress/adapters/mdx/schema";
import { blogPlugin } from "@fumapress/blog";
import { blogPageSchema, blogMetaSchema } from "@fumapress/blog/schema";
blogPlugin();
blogPlugin({ source: "blog" });If your isBlog picked posts from one source, move them to a separate source. Then update your calls to the blog helpers:
import { getBlogPosts, getBlogAuthors } from "fumapress/plugins/blog";
const posts = await getBlogPosts(ctx, lang);
const authors = await getBlogAuthors(ctx, posts[0].page);
import { getBlogPosts } from "@fumapress/blog";
const posts = await getBlogPosts(ctx, { lang });
const authors = posts[0]?.authors;- Posts come with
authors,tagsandimage. Usetag.urlin place oftagSlug(). - The
layoutsoption becomesrenderPagecases. - The adapter keys
blog:get-tags,blog:get-authorsandblog:get-imagebecome the optionsgetTags,getAuthorsandgetImage.
changelogPlugin({
isChangelog: (page) => page.type === "changelog",
});
changelogPlugin({ source: "changelog" });layouts, createChangelogLayout(), createChangelogIndexPage() and getChangelogContext() are removed. Style the page with the changelog.index case of renderPage.
Each provider has its own function, with the same options.
import { imagePlugin } from "fumapress/plugins/image/vercel";
imagePlugin({ domains: ["github.com"] });
import { vercelImage } from "fumapress/plugins/image/vercel";
vercelImage({ domains: ["github.com"] });The others are cloudflareImage() and selfHostedImage(). selfHostedImage() now needs site.baseUrl.
The default plugins add the provider of your platform. When you list a provider, with or without options, set image: false in recommendedPreset(). See Remote Images.
The options move to recommendedPreset(), since the plugin is a default one. basePath becomes path, and generate becomes getImage.
takumiPlugin({
basePath: "/og",
generate(page) {
return { node: <h1>{page.data.title}</h1> };
},
});
recommendedPreset({
ogImage: {
path: "/og",
getImage(page) {
return { node: <h1>{page.data.title}</h1> };
},
},
});Only the imports change. Each plugin is now the default export of its integration.
import { openapiPlugin } from "fumapress/plugins/openapi";
import { asyncapiPlugin } from "fumapress/plugins/asyncapi";
import openapiPlugin from "fumadocs-openapi/press";
import asyncapiPlugin from "@fumadocs/asyncapi/press";npm uninstall @fumapress/graphql
npm i @fumadocs/graphqlimport { createGraphQL, graphqlPlugin } from "@fumapress/graphql";
import { createGraphQL } from "@fumadocs/graphql/server";
import graphqlPlugin from "@fumadocs/graphql/press";@import "@fumapress/graphql/css/preset.css";
@import "@fumadocs/graphql/css/preset.css";npm uninstall @fumapress/notion
npm i @fumadocs/notionimport { fumapressNotion, notionPlugin } from "@fumapress/notion";
import { createNotion } from "@fumadocs/notion";
import notionPlugin from "@fumadocs/notion/press";
const notion = fumapressNotion({ client, dataSourceId });
const notion = createNotion({ client, dataSourceId });
notionPlugin(notion);
notionPlugin({ notion });Replace @fumapress/notion/css/preset.css with @fumadocs/notion/css/preset.css. The Renderer option is removed, so to replace it, override getPageBody of the plugin:
const plugin = { ...notionPlugin({ notion }), getPageBody };The PortableText option becomes the components of the plugin, and createSanitySource() returns the source directly.
npm uninstall @fumapress/sanity
npm i @fumadocs/sanityimport { fumapressSanity, sanityPlugin } from "@fumapress/sanity";
const sanity = fumapressSanity({
client,
docType: "docs",
PortableText: ({ value }) => <PortableText value={value} components={components} />,
});
defineConfig({ content: sanity.dynamicSource() }).plugins(sanityPlugin(sanity));
import { createSanitySource } from "@fumadocs/sanity";
import sanityPlugin from "@fumadocs/sanity/press";
const source = createSanitySource({ client, docType: "docs" });
createPress(source).plugins(sanityPlugin({ components }));npm uninstall @fumapress/obsidian
npm i fumadocs-obsidianimport { obsidian, obsidianPlugin } from "@fumapress/obsidian";
import { obsidian } from "fumadocs-obsidian";
import obsidianPlugin from "fumadocs-obsidian/press";
import { watchWithVite } from "fumadocs-obsidian/dev/vite";
const vault = obsidian({ dir: "public/vault", url: (file) => `/vault/${file}` });
if (import.meta.env.DEV) watchWithVite(vault);
obsidianPlugin(vault);
obsidianPlugin({ vault });Replace @fumapress/obsidian/css/preset.css with fumadocs-obsidian/css/preset.css.
Drop "use server" from your actions. A plain function now runs on the server, and a function from a "use client" module runs in the browser.
feedbackPlugin({
async onPageFeedbackAction(feedback) {
"use server";
await analytics.track("page-feedback", feedback);
return {};
},
});In a custom layout, place props.slots["feedback:page"].
The chat docks in the default Docs Layout, with a floating "Ask AI" button. For a custom layout, see Custom Layout.
AIChatLayout is removed, and AIChat takes the endpoint as its api prop.
Custom Plugins
Start with the type and the hooks that create routes. This plugin has a page, a generated file and a response header:
import type { AppShape, PressPlugin } from "fumapress";
export function statusPlugin<C extends AppShape = AppShape>(): PressPlugin<C> {
return {
name: "acme:status",
createPages({ createPage, createApiIsomorphic }) {
createPage({
path: "/status",
render: "static",
component: () => <p>OK</p>,
});
createApiIsomorphic({
path: "/status.json",
render: "static",
handler: async () => Response.json({ ok: true }),
});
},
createMiddlewares() {
return [
async (c, next) => {
await next();
c.header("x-powered-by", "acme");
},
];
},
};
}Then rename these APIs:
| 1.x | 2.0 |
|---|---|
AppContext | PressContext |
core:get-body | getPageBody |
core:get-text | getPageText |
core:render-toc | getPageToc |
core:get-structured-data | getPageStructuredData |
core:get-creation-date | getPageCreatedAt |
core:get-modified-date | getPageLastModified |
unstable_onServerEntry | waku.unstable_serverEntry |
enforce is removed, since plugins run in list order. preinit is removed: to replace a default plugin, the site sets it to false in recommendedPreset(). this.mode becomes this.getContentRender().
These need more than a rename:
A plugin that needs Waku's own APIs moves them under waku, and uses the WakuPlugin type. See Waku APIs.
export function acmePlugin<C extends AppShape = AppShape>(): PressPlugin<C> {
export function acmePlugin<C extends WakuAppShape = WakuAppShape>(): WakuPlugin<C> {
return {
name: "acme",
createPages({ createPage }) { /* ... */ },
createMiddlewares() { /* ... */ },
waku: {
createPages({ createPage }) { /* ... */ },
honoMiddleware() { /* ... */ },
},
};
}The renderPage hook becomes getPageView. It returns { props, node } for the pages of a source your plugin owns. See Rendering Content Pages.
renderPage({ page, fallback }) {
if (page.type !== "reviews") return;
return <article>{fallback}</article>;
},
getPageView(page, body) {
if (page.type !== "reviews") return;
return { props: {}, node: <article>{body?.node}</article> };
},configureRoutes becomes deriveRoutes, which receives the routes of each language. Head tags from route.meta move to the head hook, which receives route.
configureRoutes({ getRoutes }) {
for (const route of getRoutes()) route.meta.push(() => <meta name="acme" content="1" />);
},
head({ route }) {
if (route) return [{ tag: "meta", attrs: { name: "acme", content: "1" } }];
},this.data and its transformers and interceptors are removed. Add UI with slots(), and change page data with transformPage(). Plugin translations move to the translations field.
{
name: "acme",
init() {
this.translationsConfig?.extend(acmeTranslations());
},
translations: acmeTranslations(),
}Last updated on