DEV Community

Cover image for Rebuilding my Portfolio with Next, MDX, and Contentlayer
roze 🌹
roze 🌹

Posted on • Originally published at aroze.xyz

Rebuilding my Portfolio with Next, MDX, and Contentlayer

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). 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 -- my favorite full stack framework
  • Tailwind CSS -- because I don't know how to do CSS otherwise
  • MDX -- to use React within my markdown (probably won't use much JSX, but hey why not at least have it)
  • Contentlayer -- transform the mdx posts into type-safe json data
  • Vercel -- 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.

npm create t3-app@latest
Enter fullscreen mode Exit fullscreen mode

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

After installation, we can clear up the homepage

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;
Enter fullscreen mode Exit fullscreen mode

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
yarn add @next/mdx @mdx-js/loader @mdx-js/react gray-matter rehype-autolink-headings rehype-slug rehype-pretty-code remark-frontmatter shiki
Enter fullscreen mode Exit fullscreen mode

Setting Up Contentlayer

Contentlayer makes it super easy to grab our mdx blog posts in a type-safe way.

First install it and its associated Next js plugin

yarn add contentlayer next-contentlayer
Enter fullscreen mode Exit fullscreen mode

Modify your next.config.mjs

// 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);
Enter fullscreen mode Exit fullscreen mode

Modify your tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": ["next-env.d.ts", "**/*.tsx", "**/*.ts", ".contentlayer/generated"]
}
Enter fullscreen mode Exit fullscreen mode

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
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],
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

If you're using git, don't forget to add the generated content to your gitignore

# contentlayer
.contentlayer
Enter fullscreen mode Exit fullscreen mode

Add Post Content

Create a folder called content

Create a file in content called first-post.mdx

---
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 />
```
Enter fullscreen mode Exit fullscreen mode

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

yarn dev
Enter fullscreen mode Exit fullscreen mode

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

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,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Then update the component to show these posts

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>
      ...
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

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

  return {
    paths,
    fallback: false,
  };
};
Enter fullscreen mode Exit fullscreen mode
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,
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Setup our component

interface Props {
  post: Post;
}

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

export default BlogPost;
Enter fullscreen mode Exit fullscreen mode

Before rendering the BlogPost, we can also style some of it using Tailwind Typography

yarn add -D @tailwindcss/typography
Enter fullscreen mode Exit fullscreen mode

Add that to your tailwind.config.cjs

module.exports = {
  theme: {
    // ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    // ...
  ],
};
Enter fullscreen mode Exit fullscreen mode

Now, how do we actually render the blog post? Contentlayer gives us a NextJS specific hook useMDX that allows us to render MDX

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>
  );
Enter fullscreen mode Exit fullscreen mode

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

We can modify some of the styles in globals.css

First lets fix the typography

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

And lets style our code plugins

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];
}
Enter fullscreen mode Exit fullscreen mode

Much better :)

First Post image with styles

Top comments (0)