Rebuilding my Portfolio with Next, MDX, and Contentlayer
Tags: nextjs, contentlayer, t3, tutorial
Published: 2023-03-22 21:19:49

## Why I Stopped Using Ghost

I liked the idea of opening up my iPad, sipping on a caramel latte in an overly-hipster Brooklyn cafe, writing a new tech post. Ghost CMS was my way to do that ([see my setup](https://dev.to/emotionaldaffodil/building-a-blog-with-nextjs-and-ghost-5ehc)). It was, however, expensive ever since Heroku broke up with us and I moved onto Digital Ocean which is $6 month. But also, sometimes Ghost would crash and I didn't want to spend too long debugging when redeploying quickly fixed whatever was broken.

Ultimately, crashes and money didn't warrant a ridiculous aesthetic of writing in a cafe because I never actually did it. Caramel lattes are also expensive.

And I can also use Obsidian, my markdown notetaker, and then just copy that to my blog, achieving all of this for free.

## Technologies

- [Next JS](https://nextjs.org/) -- my favorite full stack framework
- [Tailwind CSS](https://tailwindcss.com/) -- because I don't know how to do CSS otherwise
- [MDX](https://nextjs.org/docs/advanced-features/using-mdx) -- to use React within my markdown (probably won't use much JSX, but hey why not at least have it)
- [Contentlayer](https://www.contentlayer.dev/) -- transform the mdx posts into type-safe json data
- [Vercel](https://vercel.com/home) -- deployment

## Getting Started

I've started using the T3 CLI to make my apps these days because the stack generally is one I enjoy and I love the cohesion together.

```bash
npm create t3-app@latest
```

Only select Tailwind, we don't need the other packages

After installation, we can clear up the homepage

```tsx
import { type NextPage } from 'next';
import Head from 'next/head';
const Home: NextPage = () => {
  return (
    <>
      <Head>
        <title>Create T3 App</title>
        <meta name="description" content="Generated by create-t3-app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <main className="flex min-h-screen flex-col items-center bg-gradient-to-b from-[#2e026d] to-[#15162c] pt-20">
        <h1 className="text-7xl font-bold text-white">My Cool Blog</h1>
      </main>
    </>
  );
};

export default Home;
```
## Configuring MDX

To be able to write `.mdx` files, we'll need a few plugins

- @next/mdx -- to use with Next
- @mdx-js/loader -- required package of @next/mdx
- @mdx-js/react -- required package of @next/mdx
- gray-matter -- to ignore frontmatter from rendering
- rehype-autolink-headings -- allows to add links to headings with ids on there already
- rehype-slug -- allows to add links to headings for documents that don't already have ids
- rehype-pretty-code -- makes code pretty with syntax highlighting, line numbers, etc
- remark-frontmatter -- plugin to support frontmatter
- shiki -- coding themes we can use for rendering code snippets

```bash
yarn add @next/mdx @mdx-js/loader @mdx-js/react gray-matter rehype-autolink-headings rehype-slug rehype-pretty-code remark-frontmatter shiki
```

## Setting Up Contentlayer

[Contentlayer](https://www.contentlayer.dev/) makes it super easy to grab our mdx blog posts in a type-safe way.

First install it and its associated Next js plugin

```bash
yarn add contentlayer next-contentlayer
```

Modify your `next.config.mjs`

```js
// next.config.mjs

import { withContentlayer } from 'next-contentlayer';

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Configure pageExtensions to include md and mdx
  pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
  reactStrictMode: true,
  swcMinify: true,
};

// Merge MDX config with Next.js config
export default withContentlayer(nextConfig);
```
Modify your `tsconfig.json`

```json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": ["next-env.d.ts", "**/*.tsx", "**/*.ts", ".contentlayer/generated"]
}
```

Create a file `contentlayer.config.ts` and we will do three things

1. Define the schema of our Post and where the content lives
2. Setup our remark and rehype plugins

```typescript
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import remarkFrontmatter from 'remark-frontmatter';

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: {
      type: 'string',
      description: 'The title of the post',
      required: true,
    },
    excerpt: {
      type: 'string',
      description: 'The excerpt of the post',
      required: true,
    },
    date: {
      type: 'string',
      description: 'The date of the post',
      required: true,
    },
    coverImage: {
      type: 'string',
      description: 'The cover image of the post',
      required: false,
    },
    ogImage: {
      type: 'string',
      description: 'The og cover image of the post',
      required: false,
    },
  },
  computedFields: {
    url: {
      type: 'string',
      resolve: (post) => `/blog/${post._raw.flattenedPath}`,
    },
    slug: {
      type: 'string',
      resolve: (post) => post._raw.flattenedPath,
    },
  },
}));

const prettyCodeOptions = {
  theme: 'material-theme-palenight',

  onVisitLine(node: { children: string | unknown[] }) {
    if (node.children.length === 0) {
      node.children = [{ type: 'text', value: ' ' }];
    }
  },

  onVisitHighlightedLine(node: { properties: { className: string[] } }) {
    node.properties.className.push('highlighted');
  },

  onVisitHighlightedWord(node: { properties: { className: string[] } }) {
    node.properties.className = ['highlighted', 'word'];
  },
};

export default makeSource({
  contentDirPath: 'content',
  documentTypes: [Post],
  mdx: {
    remarkPlugins: [remarkFrontmatter],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
      [rehypePrettyCode, prettyCodeOptions],
    ],
  },
});
```
If you're using git, don't forget to add the generated content to your `gitignore`

```txt
# contentlayer
.contentlayer
```

## Add Post Content

Create a folder called `content`

Create a file in `content` called `first-post.mdx`

~~~markdown
---
title: First Post
excerpt: My first ever post on my blog
date: '2022-02-16'
---
# Hello World

My name is Roze and I built this blog to do cool things

- Like talking about pets
- And other cool stuff

## Random Code

```mdx {1,15} showLineNumbers title="Page.mdx"
import { MyComponent } from '../components/...';

# My MDX page

This is an unordered list

- Item One
- Item Two
- Item Three

<section>And here is _markdown_ in **JSX**</section>

Checkout my React component

<MyComponent />
```
~~~

Once you've created a new post, make sure to run your app to trigger contentlayer to generate

```bash
yarn dev
```

You should see a new folder called `.contentlayer` which will have a `generated` folder that defines your schemas and types.

## Display All Blog Posts

We can use `getStaticProps` to pull data from our `content` folder because contentlayer provides us with `allPosts`

```tsx
import { allPosts } from "../../.contentlayer/generated";
import { type GetStaticProps } from "next";
...
export const getStaticProps: GetStaticProps = () => {
  const posts = allPosts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
  );

  return {
    props: {
      posts,
    },
  };
};
```

Then update the component to show these posts

```tsx
interface Props {
  posts: Post[];
}

const Home: NextPage<Props> = ({ posts }) => {
  return (
    <>
      ...
      <ul className="pt-20">
        {posts.map((post, index) => (
          <li key={index} className="space-y-2 py-2 text-white">
            <h1 className="text-4xl font-semibold hover:text-yellow-200">
              <Link href={post.url}>{post.title} ↗️</Link>
            </h1>
            <h2>{post.excerpt}</h2>
          </li>
        ))}
      </ul>
      ...
    </>
  );
};
```

## Render a Single Post

Now when a user clicks on one of the posts, we should send them to a new page that shows the full post.

Create a new folder in `pages` called `blog` and make a file `[slug].tsx`

We'll meed to define `getStaticPaths` to generate the dynamic routes and `getStaticProps` to retrieve and return a single post

```tsx
export const getStaticPaths: GetStaticPaths = () => {
  const paths = allPosts.map((post) => post.url);

  return {
    paths,
    fallback: false,
  };
};
```

```tsx
interface IContextParams extends ParsedUrlQuery {
  slug: string;
}

export const getStaticProps: GetStaticProps = (context) => {
  const { slug } = context.params as IContextParams;
  const post = allPosts.find((post) => post.slug === slug);

  if (!post) {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      post,
    },
  };
};
```

Setup our component

```tsx 
interface Props {
  post: Post;
}

const BlogPost: NextPage<Props> = ({ post }) => {
  return <></>;
};

export default BlogPost;
```

Before rendering the BlogPost, we can also style some of it using [Tailwind Typography](https://tailwindcss.com/docs/typography-plugin)

```bash
yarn add -D @tailwindcss/typography
```

Add that to your `tailwind.config.cjs`

```javascript
module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
};
```

Now, how do we actually render the blog post? Contentlayer gives us a NextJS specific hook [`useMDX`](https://www.contentlayer.dev/docs/reference/next-contentlayer#usemdxcomponent) that allows us to render MDX

```tsx 
import { useMDXComponent } from "next-contentlayer/hooks";
...
  const Component = useMDXComponent(post.body.code);
  return (
    <main className="flex min-h-screen flex-col items-center bg-gradient-to-b from-[#2e026d] to-[#15162c] pt-20">
      <header>
        <h1 className="pb-10 text-7xl text-white">{post.title}</h1>
      </header>
      <article className="prose">
        <Component />
      </article>
    </main>
  );
```

In the above code we `useMDX` allows us to render our mdx and the `className='prose'` applies the Tailwind Typography styles on the content.

But our post looks gross.


![First Post image](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f41u4hijbb61sox2do6x.png)

We can modify some of the styles in `globals.css`

First lets fix the typography

```css
.prose :is(h1, h2, h3, h4, h5, h6) > a {
  @apply no-underline text-white;
}
.prose {
  @apply text-white;
}
```

And lets style our code plugins

```css
code[data-line-numbers] {
  padding-left: 0 !important;
  padding-right: 0 !important;
}

code[data-line-numbers] > .line::before {
  counter-increment: line;
  content: counter(line);
  display: inline-block;
  width: 1rem;
  margin-right: 1.25rem;
  margin-left: 0.75rem;
  text-align: right;
  color: #676e95;
}

div[data-rehype-pretty-code-title] + pre {
  @apply !mt-0 !rounded-tl-none;
}

div[data-rehype-pretty-code-title] {
  @apply !mt-6 !max-w-max !rounded-t !border-b !border-b-slate-400 !bg-[#2b303b] !px-4 !py-0.5 !text-gray-300 dark:!bg-[#282c34];
}
```

Much better :)


![First Post image with styles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/phuit8qf58qi7n8jyyky.png)