Blog

Build a blog system with Fumapress.

Installation

This plugin is included in fumapress.

Fumadocs MDX

If you use Fumadocs MDX, create a new collection for blog and register it as a content source:

press.config.tsx
import { defineConfig } from "fumapress";
import { blogMetaSchema, blogPageSchema } from "fumapress/adapters/mdx/schema";
import { blogPlugin } from "fumapress/plugins/blog";
import { defineDocs } from "fumadocs-mdx/macro";

const docs = defineDocs({
  dir: "content/docs",
  docs: { async: true },
});

const blog = defineDocs({
  dir: "content/blog",
  docs: {
    async: true,
    schema: blogPageSchema,
    lastModified: true,
    postprocess: {
      includeProcessedMarkdown: true,
    },
  },
  meta: {
    schema: blogMetaSchema,
  },
});

export default defineConfig({
  content: {
    docs: docs.toFumadocsSource(),
    blog: blog.toFumadocsSource({
      baseDir: "blog",
    }),
  },
})
  .plugins(blogPlugin());

The plugin overrides the renderer for blog pages, by default, all pages from the blog content source are rendered as blog posts.

You can also define a isBlog() option to determine whether the page is a blog post

press.config.tsx
import { defineConfig } from "fumapress";
import { blogPlugin } from "fumapress/plugins/blog";

export default defineConfig({
  // ...
}).plugins(
  blogPlugin({
    isBlog: (page) => page.type === "blog",
  }),
);

The integration is now configured, you can write blog posts under the content/blog directory (as specified in defineDocs()):

content/blog/hello-world.mdx
---
title: My Blog Post
description: Hello World!!!
date: 2026-09-06
tags: [my-tag]
---

## Introduction

Welcome to my first blog post.

blogPageSchema supports the following frontmatter fields:

FieldDescription
titleThe post title (required)
descriptionShown below the title and on post cards
datePublish date, posts are sorted by it
tagsPost tags, each tag gets a page under /blog/tags
authorsAuthor ids, see the authors option
imageCover image URL, shown on post cards

Cover images and author avatars render through the Image component. Paths under your own site work as they are, remote URLs need their hostname allowed by the image plugin:

press.config.tsx
import { imagePlugin } from "fumapress/plugins/image/self-hosted";

export default defineConfig({
  // ...
}).plugins(imagePlugin({ allowedHosts: ["avatars.githubusercontent.com"] }));

Watch for redirects

A redirect that leaves the origin is checked again, so allow the final host too. github.com/<user>.png, for example, redirects to avatars.githubusercontent.com.

Other Content Sources

If you don't use Fumadocs MDX, you can keep the your content config, but make sure to define the isBlog() option like:

press.config.tsx
import { defineConfig } from "fumapress";
import { blogPlugin } from "fumapress/plugins/blog";
import { myCms } from "./my-cms";

export default defineConfig({
  content: myCms.toStaticSource(),
}).plugins(
  blogPlugin({
    // since it is not using multi-source, `page.type` will be `undefined`.
    // you need to detect it from other properties, like page path.
    isBlog: (page) => page.path.startsWith("blog/"),
  }),
);

Post data is resolved through adapter hooks: core:get-creation-date for the date, blog:get-tags, blog:get-authors and blog:get-image for the other fields. The Fumadocs MDX adapter reads them from frontmatter, for other sources, implement the hooks in your adapter.

Options

paths

The plugin will create additional pages, you can override their pathnames.

press.config.tsx
import { defineConfig } from "fumapress";
import { blogPlugin } from "fumapress/plugins/blog";

export default defineConfig({
  // ...
}).plugins(
  blogPlugin({
    paths: {
      index: "/blog",
      tags: "/blog/tags",
    },
  }),
);

You can set to false to disable a certain page.

authors

Register authors once, keyed by the ids used in the authors frontmatter of posts.

press.config.tsx
import { defineConfig } from "fumapress";
import { blogPlugin } from "fumapress/plugins/blog";

export default defineConfig({
  // ...
}).plugins(
  blogPlugin({
    authors: {
      fuma: {
        name: "Fuma Nama",
        title: "Maintainer",
        url: "https://fuma-nama.dev",
        image: "https://github.com/fuma-nama.png",
      },
    },
  }),
);
content/blog/hello-world.mdx
---
title: My Blog Post
authors: [fuma]
---

Prop

Type

Ids missing from the registry are shown by name only.

layouts

Override the UI.

press.config.tsx
import { defineConfig } from "fumapress";
import { blogPlugin } from "fumapress/plugins/blog";
// blog layouts
import { createHomeLayout } from "fumapress/layouts/home";
import { createBlogLayoutPage } from "fumapress/layouts/blog";
import { createBlogIndexPage } from "fumapress/layouts/blog.index";
import { createBlogTagPage, createBlogTagsPage } from "fumapress/layouts/blog.tags";

const HomeLayout = createHomeLayout<typeof config.$context>();
const BlogPage = createBlogLayoutPage<typeof config.$context>();
const BlogIndex = createBlogIndexPage<typeof config.$context>();
const BlogTags = createBlogTagsPage<typeof config.$context>();
const BlogTag = createBlogTagPage<typeof config.$context>();

const config = defineConfig({
  // ...
}).plugins(
  blogPlugin({
    layouts: {
      // shared layout for blog
      layout: (props) => <HomeLayout {...props} />,
      // renderer for blog pages
      page: (props) => <BlogPage {...props} />,
      // renderer for blog's index page
      index: (props) => <BlogIndex {...props} />,
      // renderer for blog's tags list
      tags: (props) => <BlogTags {...props} />,
      // renderer for blog's tag info page
      tag: (props) => <BlogTag {...props} />,
    },
  }),
);

export default config;

Custom Layouts

When writing your own layouts, fumapress/plugins/blog exports helpers for blog data, so you don't read the loader and adapters yourself:

  • getBlogPosts(ctx, lang?): posts of a locale as { page, date }, newest first.
  • getBlogAuthors(ctx, page): authors of a post, resolved with the authors option.
  • tagSlug(tag): URL segment of a tag, for links to tag pages.
press.config.tsx
import { getPressContext } from "fumapress";
import { getBlogAuthors, getBlogPosts, type BlogLayoutPage } from "fumapress/plugins/blog";

const BlogPage: BlogLayoutPage<typeof config.$context> = async ({ page }) => {
  const ctx = getPressContext<typeof config.$context>();
  const authors = await getBlogAuthors(ctx, page);
  const posts = await getBlogPosts(ctx, page.locale);
  const index = posts.findIndex((post) => post.page.url === page.url);
  const older = posts[index + 1];

  return (
    <article>
      <h1>{page.data.title}</h1>
      <p>{authors.map((author) => author.name).join(", ")}</p>
      {(await ctx.getPageBody(page))?.node}
      {older && <a href={older.page.url}>{older.page.data.title}</a>}
    </article>
  );
};

Tag pages receive the slug as tag, link to them with tagSlug():

import { tagSlug } from "fumapress/plugins/blog";

<a href={`/blog/tags/${tagSlug(tag)}`}>{tag}</a>;

Last updated on

On this page