It’s possible to switch to Content Collections to generate type-safe data collections from MDX files. This approach provides a structured way to manage blog posts while maintaining full type safety throughout your application.

1. Swap out the required dependencies

Remove the existing dependencies…

Terminal
pnpm remove basehub --filter @repo/cms

… and install the new dependencies…

Terminal
pnpm add @content-collections/mdx fumadocs-core --filter @repo/cms
pnpm add -D @content-collections/cli @content-collections/core @content-collections/next --filter @repo/cms

2. Update the .gitignore file

Add .content-collections to the .gitignore file:

apps/web/.gitignore
# content-collections
.content-collections

3. Modify the CMS package scripts

Now we need to modify the CMS package scripts to replace the basehub commands with content-collections.

packages/cms/package.json
{
  "scripts": {
    "dev": "content-collections build",
    "build": "content-collections build",
    "analyze": "content-collections build"
  },
}

We’re using the Content Collections CLI directly to generate the collections prior to Next.js processes. The files are cached and not rebuilt in the Next.js build process. This is a workaround for this issue.

4. Modify the relevant CMS package files

5. Update the sitemap.ts file

Update the sitemap.ts file to scan the content directory for MDX files:

apps/web/app/sitemap.ts
// ...

const blogs = fs
  .readdirSync('content/blog', { withFileTypes: true })
  .filter((file) => !file.isDirectory())
  .filter((file) => !file.name.startsWith('_'))
  .filter((file) => !file.name.startsWith('('))
  .map((file) => file.name.replace('.mdx', ''));

const legals = fs
  .readdirSync('content/legal', { withFileTypes: true })
  .filter((file) => !file.isDirectory())
  .filter((file) => !file.name.startsWith('_'))
  .filter((file) => !file.name.startsWith('('))
  .map((file) => file.name.replace('.mdx', ''));

// ...

6. Create your collections

Create a new content collections file in the cms package, then import the collections in the web package.

We’re remapping the title field to _title and the _meta.path field to _slug to match the default next-forge CMS.

7. Create your content

To create a new blog post, add a new MDX file to the apps/web/content/blog directory. The file name will be used as the slug for the blog post and the frontmatter will be used to generate the blog post page. For example:

apps/web/content/blog/my-first-post.mdx
---
title: 'My First Post'
description: 'This is my first blog post'
date: 2024-10-23
image: /blog/my-first-post.png
---

The same concept applies to the legal collection, which is used to generate the legal policy pages. Also, the image field is the path relative to the app’s root public directory.

8. Remove the environment variables

Finally, remove all instances of BASEHUB_TOKEN from the @repo/env package.

9. Bonus features

Fumadocs MDX Plugins

You can use the Fumadocs MDX plugins to enhance your MDX content.

import {
  type RehypeCodeOptions,
  rehypeCode,
  remarkGfm,
  remarkHeading,
} from 'fumadocs-core/mdx-plugins';

const rehypeCodeOptions: RehypeCodeOptions = {
  themes: {
    light: 'catppuccin-mocha',
    dark: 'catppuccin-mocha',
  },
};

const posts = defineCollection({
  // ...
  transform: async (page, context) => {
    // ...
    const body = await context.cache(page.content, async () =>
      compileMDX(context, page, {
        remarkPlugins: [remarkGfm, remarkHeading],
        rehypePlugins: [[rehypeCode, rehypeCodeOptions]],
      })
    );

    // ...
  },
});

Reading Time

You can calculate reading time for your collection by adding a transform function.

import readingTime from 'reading-time';

const posts = defineCollection({
  // ...
  transform: async (page, context) => {
    // ...

    return {
      // ...
      readingTime: readingTime(page.content).text,
    };
  },
});

Low-Quality Image Placeholder (LQIP)

You can generate a low-quality image placeholder for your collection by adding a transform function.

import { sqip } from 'sqip';

const posts = defineCollection({
  // ...
  transform: async (page, context) => {
    // ...

    const blur = await context.cache(page._meta.path, async () =>
      sqip({
        input: `./public/${page.image}`,
        plugins: [
          'sqip-plugin-primitive',
          'sqip-plugin-svgo',
          'sqip-plugin-data-uri',
        ],
      })
    );

    const result = Array.isArray(blur) ? blur[0] : blur;

    return {
      // ...
      image: page.image,
      imageBlur: result.metadata.dataURIBase64 as string,
    };
  },
});