Three polygons connected

Rebuilding my Portfolio with Next, MDX, and Contentlayer

⏳ 5 min read

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

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.

Terminal window
npm create t3-app@latest

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

After installation, we can clear up the homepage

index.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

Terminal window
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

Terminal window
yarn add contentlayer next-contentlayer

Modify your next.config.mjs

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

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
contentlayer.config.ts
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

.gitignore
# contentlayer
.contentlayer

Add Post Content

Create a folder called content

Create a file in content called first-post.mdx

first-post.dx
---
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

Terminal window
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

index.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

index.tsx
interface Props {
posts: Post[];
}
const Home: NextPage<Props> = ({ posts }) => {
r