Skip to Content
Fumadocs

Blog

Add a blog to your app.

Installation

npx @fumadocs/cli@latest add blog

It installs the components to components/blog, and the utilities of posts to lib/blog.ts.

Define a collection for your posts with blogPageSchema, and register your authors:

lib/source.ts
import { defineCollections } from 'fumadocs-mdx/macro';
import { loader } from 'fumadocs-core/source';
import { type BlogAuthor, blogPageSchema } from './blog';

export const blog = defineCollections({
  type: 'doc',
  dir: 'content/blog',
  schema: blogPageSchema,
});

export const blogSource = loader({
  baseUrl: '/blog',
  source: blog.toFumadocsSource(),
});

export const blogAuthors: Record<string, BlogAuthor> = {
  fuma: {
    name: 'Fuma Nama',
    title: 'Maintainer',
    url: 'https://fuma-nama.dev',
    image: 'https://avatars.githubusercontent.com/u/76240755',
  },
};

Write your posts under content/blog:

content/blog/hello-world.mdx
---
title: Hello World
description: My first post.
date: 2026-10-12
tags: [Announcement]
authors: [fuma]
image: /blog/hello-world.png
---
FieldDescription
datePublish date, posts are sorted by it
tagsEach tag has its page under /blog/tags
authorsKeys of your authors, unknown keys are names
imageCover image URL, shown on the cards of posts

Pages

getPosts() resolves the authors and tags of pages, newest first. Render them in routes under your Home Layout.

Posts

src/pages/(home)/blog/index.tsx
import { blogAuthors, blogSource } from '@/lib/source';
import { getPosts } from '@/lib/blog';
import { BlogCards } from '@/components/blog';

export default function Page() {
  return (
    <div className="mx-auto w-full max-w-(--fd-layout-width) px-4 py-12">
      <BlogCards posts={getPosts(blogSource.getPages(), blogAuthors)} />
    </div>
  );
}

export const getConfig = async () => ({ render: 'static' }) as const;

Post

findPost() finds a post along with the newer and older posts. With toc, a floating panel shows the table of contents and a share button.

src/pages/(home)/blog/[slug].tsx
import type { PageProps } from 'waku/router';
import { unstable_notFound } from 'waku/router/server';
import { blogAuthors, blogSource } from '@/lib/source';
import { findPost, getPosts } from '@/lib/blog';
import { getMDXComponents } from '@/components/mdx';
import {
  BlogAuthors,
  BlogBackLink,
  BlogBody,
  BlogDate,
  BlogDescription,
  BlogHeader,
  BlogPage,
  BlogPostNav,
  BlogTags,
  BlogTitle,
} from '@/components/blog';

export default function Page({ slug }: PageProps<'/blog/[slug]'>) {
  const page = blogSource.getPage([slug]);
  const found = page && findPost(getPosts(blogSource.getPages(), blogAuthors), page.url);
  if (!page || !found) unstable_notFound();

  const { post, newer, older } = found;
  const MDX = page.data.body;

  return (
    <BlogPage toc={page.data.toc}>
      <BlogHeader>
        <BlogBackLink href="/blog" />
        <BlogTitle>{post.title}</BlogTitle>
        <BlogDescription>{post.description}</BlogDescription>
        <BlogAuthors authors={post.authors} />
        <BlogTags tags={post.tags} />
        {post.date && <BlogDate date={post.date} />}
      </BlogHeader>
      <BlogBody>
        <MDX components={getMDXComponents()} />
      </BlogBody>
      <BlogPostNav newer={newer} older={older} />
    </BlogPage>
  );
}

export async function getConfig() {
  return {
    render: 'static',
    staticPaths: blogSource.getPages().map((page) => page.slugs),
  } as const;
}

Tags

List all tags with getTags():

src/pages/(home)/blog/tags/index.tsx
import { blogSource } from '@/lib/source';
import { getPosts, getTags } from '@/lib/blog';
import { BlogHeader, BlogTagCards, BlogTitle } from '@/components/blog';

export default function Page() {
  const tags = getTags(getPosts(blogSource.getPages()));

  return (
    <div className="mx-auto flex w-full max-w-(--fd-layout-width) flex-col gap-4 px-4 py-12">
      <BlogHeader>
        <BlogTitle>All Tags</BlogTitle>
      </BlogHeader>
      <BlogTagCards tags={tags} />
    </div>
  );
}

export const getConfig = async () => ({ render: 'static' }) as const;

And the posts of a tag with getTagPosts(), which returns undefined for unknown tags:

src/pages/(home)/blog/tags/[tag].tsx
import type { PageProps } from 'waku/router';
import { unstable_notFound } from 'waku/router/server';
import { blogAuthors, blogSource } from '@/lib/source';
import { getPosts, getTagPosts, getTags } from '@/lib/blog';
import { BlogCards, BlogHeader, BlogTitle } from '@/components/blog';

export default function Page({ tag }: PageProps<'/blog/tags/[tag]'>) {
  const result = getTagPosts(getPosts(blogSource.getPages(), blogAuthors), tag);
  if (!result) unstable_notFound();

  return (
    <div className="mx-auto flex w-full max-w-(--fd-layout-width) flex-col gap-4 px-4 py-12">
      <BlogHeader>
        <BlogTitle>{result.tag.name}</BlogTitle>
      </BlogHeader>
      <BlogCards posts={result.posts} />
    </div>
  );
}

export async function getConfig() {
  return {
    render: 'static',
    staticPaths: getTags(getPosts(blogSource.getPages())).map((tag) => tag.slug),
  } as const;
}

Internationalization

Pass the pages of a locale to getPosts(), like blogSource.getPages(lang).

The URLs of tag pages come from getBlogUrl in lib/blog.ts. Pass it your i18n config to match the URLs of your loader:

lib/blog.ts
import { i18n } from '@/lib/i18n';

export const getBlogUrl = createGetUrl('/blog', i18n);

To publish a feed of your posts, see RSS Feed.

How is this guide?

Last updated on