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 .gitignore
file:
# 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
Next.js Config
export { withContentCollections as withCMS } from '@content-collections/next';
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
{
"compilerOptions": {
"paths": {
"content-collections": ["./.content-collections/generated"]
}
}
}
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 file in the cms
package, then import the collections in the web
package.
title
field to _title
and the _meta.path
field to _slug
to match the default next-forge CMS.CMS
import { defineCollection, defineConfig } from '@content-collections/core';
import { compileMDX } from '@content-collections/mdx';
const posts = defineCollection({
name: 'posts',
directory: 'content/blog',
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: 'legal',
directory: 'content/legal',
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
export { default } from '@repo/cms/collections';
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:
---
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,
};
},
});