Switch to Content Collections
How to switch to Content Collections.
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...
pnpm remove basehub --filter @repo/cms
... and install the new dependencies...
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 root .gitignore
file (in the root of your monorepo):
# 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
.
{
"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
You may see TypeScript errors during this step. These will be resolved after you create your collections configuration and run the first build in step 6.
Next.js Config (CMS Package)
Update the CMS package's Next.js config to export the Content Collections wrapper:
export { withContentCollections as withCMS } from '@content-collections/next';
This replaces the previous BaseHub configuration and maintains compatibility with your existing next.config.ts
in the web app.
Collections
import { allPosts, allLegals } from 'content-collections';
export const blog = {
postsQuery: null,
latestPostQuery: null,
postQuery: (slug: string) => null,
getPosts: async () => allPosts,
getLatestPost: async () =>
allPosts.sort((a, b) => a.date.getTime() - b.date.getTime()).at(0),
getPost: async (slug: string) =>
allPosts.find(({ _meta }) => _meta.path === slug),
};
export const legal = {
postsQuery: null,
latestPostQuery: null,
postQuery: (slug: string) => null,
getPosts: async () => allLegals,
getLatestPost: async () =>
allLegals.sort((a, b) => a.date.getTime() - b.date.getTime()).at(0),
getPost: async (slug: string) =>
allLegals.find(({ _meta }) => _meta.path === slug),
};
Components
import { MDXContent } from '@content-collections/mdx/react';
import type { ComponentProps } from 'react';
type BodyProperties = Omit<ComponentProps<typeof MDXContent>, 'code'> & {
content: ComponentProps<typeof MDXContent>['code'];
};
export const Body = ({ content, ...props }: BodyProperties) => (
<MDXContent {...props} code={content} />
);
TypeScript Config
Update your tsconfig.json
in the apps/web
directory to add the path mapping:
{
"compilerOptions": {
"paths": {
"content-collections": ["./.content-collections/generated"]
}
}
}
Make sure to merge this with your existing compilerOptions.paths
if you have any.
Toolbar
export const Toolbar = () => null;
Table of Contents
import { getTableOfContents } from 'fumadocs-core/server';
type TableOfContentsProperties = {
data: string;
};
export const TableOfContents = async ({
data,
}: TableOfContentsProperties) => {
const toc = await getTableOfContents(data);
return (
<ul className="flex list-none flex-col gap-2 text-sm">
{toc.map((item) => (
<li
key={item.url}
style={{
paddingLeft: `${item.depth - 2}rem`,
}}
>
<a
href={item.url}
className="line-clamp-3 flex rounded-sm text-foreground text-sm underline decoration-foreground/0 transition-colors hover:decoration-foreground/50"
>
{item.title}
</a>
</li>
))}
</ul>
);
};
5. Update the sitemap.ts
file
Update the sitemap.ts
file to scan the content
directory for MDX files:
// ...
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 configuration file in the cms
package, then create a re-export file in the web
app.
title
field to _title
and the _meta.path
field to _slug
to match the default next-forge CMS.CMS Package
import { defineCollection, defineConfig } from '@content-collections/core';
import { compileMDX } from '@content-collections/mdx';
const posts = defineCollection({
name: 'posts',
directory: 'content/blog', // relative to apps/web
include: '**/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
date: z.string(),
image: z.string(),
authors: z.array(z.string()),
tags: z.array(z.string()),
}),
transform: async ({ title, ...page }, context) => {
const body = await context.cache(page.content, async () =>
compileMDX(context, page)
);
return {
...page,
_title: title,
_slug: page._meta.path,
body,
};
},
});
const legals = defineCollection({
name: 'legals',
directory: 'content/legal', // relative to apps/web
include: '**/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
date: z.string(),
}),
transform: async ({ title, ...page }, context) => {
const body = await context.cache(page.content, async () =>
compileMDX(context, page)
);
return {
...page,
_title: title,
_slug: page._meta.path,
body,
};
},
});
export default defineConfig({
collections: [posts, legals],
});
Web App
Create a configuration file in the root of your web
app:
export { default } from '@repo/cms/collections';
After creating these files, you'll need to run pnpm build
in the packages/cms
directory to generate the types. TypeScript errors about missing content-collections
module will resolve after the first build.
7. Create your content
Create the content directories if they don't exist:
apps/web/content/blog
for blog postsapps/web/content/legal
for legal pages
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:
---
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,
};
},
});