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 H…


This content originally appeared on DEV Community and was authored by roze 🌹

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

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;

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

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

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);

Modify your tsconfig.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
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

# contentlayer
.contentlayer

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 />
```

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

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

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

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

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

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

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

yarn add -D @tailwindcss/typography

Add that to your tailwind.config.cjs

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

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>
  );

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;
}

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];
}

Much better :)

First Post image with styles


This content originally appeared on DEV Community and was authored by roze 🌹


Print Share Comment Cite Upload Translate Updates
APA

roze 🌹 | Sciencx (2023-03-22T21:19:49+00:00) Rebuilding my Portfolio with Next, MDX, and Contentlayer. Retrieved from https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/

MLA
" » Rebuilding my Portfolio with Next, MDX, and Contentlayer." roze 🌹 | Sciencx - Wednesday March 22, 2023, https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/
HARVARD
roze 🌹 | Sciencx Wednesday March 22, 2023 » Rebuilding my Portfolio with Next, MDX, and Contentlayer., viewed ,<https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/>
VANCOUVER
roze 🌹 | Sciencx - » Rebuilding my Portfolio with Next, MDX, and Contentlayer. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/
CHICAGO
" » Rebuilding my Portfolio with Next, MDX, and Contentlayer." roze 🌹 | Sciencx - Accessed . https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/
IEEE
" » Rebuilding my Portfolio with Next, MDX, and Contentlayer." roze 🌹 | Sciencx [Online]. Available: https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/. [Accessed: ]
rf:citation
» Rebuilding my Portfolio with Next, MDX, and Contentlayer | roze 🌹 | Sciencx | https://www.scien.cx/2023/03/22/rebuilding-my-portfolio-with-next-mdx-and-contentlayer/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.