> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zopio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Switch to Content Collections

> How to switch to Content Collections.

It's possible to switch to [Content Collections](https://www.content-collections.dev/) 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...

```sh Terminal theme={"system"}
pnpm remove basehub --filter @repo/cms
```

... and install the new dependencies...

```sh Terminal theme={"system"}
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:

```git apps/web/.gitignore {1-2} theme={"system"}
# 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`.

```json packages/cms/package.json {3-5} theme={"system"}
{
  "scripts": {
    "dev": "content-collections build",
    "build": "content-collections build",
    "analyze": "content-collections build"
  },
}
```

<Note>
  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](https://github.com/sdorra/content-collections/issues/214).
</Note>

## 4. Modify the relevant CMS package files

<CodeGroup>
  ```ts packages/cms/next-config.ts theme={"system"}
  export { withContentCollections as withCMS } from '@content-collections/next';
  ```

  ```ts packages/cms/index.ts theme={"system"}
  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),
  };
  ```

  ```tsx packages/cms/components/body.tsx theme={"system"}
  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} />
  );
  ```

  ```ts packages/cms/typescript-config.json theme={"system"}
  {
    "compilerOptions": {
      "paths": {
        "content-collections": ["./.content-collections/generated"]
      }
    }
  }
  ```

  ```tsx packages/cms/components/toolbar.tsx theme={"system"}
  export const Toolbar = () => null;
  ```

  ```tsx packages/cms/components/toc.tsx theme={"system"}
  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>
    );
  };
  ```
</CodeGroup>

## 5. Update the `sitemap.ts` file

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

```tsx apps/web/app/sitemap.ts theme={"system"}
// ...

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.

<Note>We're remapping the `title` field to `_title` and the `_meta.path` field to `_slug` to match the default `zopio` CMS.</Note>

<CodeGroup>
  ```ts packages/cms/collections.ts theme={"system"}
  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],
  });
  ```

  ```ts packages/web/content-collections.ts theme={"system"}
  export { default } from '@repo/cms/collections';
  ```
</CodeGroup>

## 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:

```mdx apps/web/content/blog/my-first-post.mdx theme={"system"}
---
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](/migrations/documentation/fumadocs) MDX plugins to enhance your MDX content.

```ts {1-6,8-13,20-23} theme={"system"}
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.

```ts {1,10} theme={"system"}
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.

```ts {1,8-19,23,24} theme={"system"}
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,
    };
  },
});
```
