By Sean Oliver ยท August 10, 2023
I've been happy with Substack for my coding newsletter, but I've been wanting a place to house more evergreen notes and takeaways about the things I'm learning that I can reference later.
Also, working more closely with Swyx lately has inspired me to do more of my learning in public and particularly to do it in a place that lends itself to applying an annealing policy, an evergreen content store that allows me to continuously refine ideas and improve on them over time.
Since I'm already using Next.js on this site, Contentlayer appeared to be the most natural choice for managing this kind of content.
Contentlayer is a type-safe content SDK that turns your Next.js app into a kind of headless CMS, allowing you to store your content in Markdown or MDX files, and then import it into your app as data.
This means you can:
The Getting Started Guide is a great starting off point, but there are still some implementation details I thought worth documenting as my setup here evolves.
As of this writing, the current version of contentlayer@0.3.4
is
not compatible
with the Next.js 13 App Router. So for now it's best to install ^0.3.1
of both
contentlayer
and next-contentlayer
:
npm install contentlayer^0.3.1 next-contentlayer^0.3.1
Or specify this version in your package.json
:
{
"dependencies": {
"contentlayer": "^0.3.1",
"next-contentlayer": "^0.3.1"
}
}
contentlayer.config.ts
In Contentlayer, your content is turned into data, and contentlayer.config.ts
is where you define the shape of that data along with any other plugins or
settings you want to employ in the MDX parsing process.
Post
ObjectIt's worth noting you can have different types of data here, but in most cases
you'll just need a single Post
object. You can think of it kind of like a
schema for your content, where you indicate the different pieces of metadata you
want to include with your posts.
Mine looks like this, and I've highlighted the places where my setup differs from the Getting Started Guide:
const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `**/*.mdx`,
contentType: 'mdx',
fields: {
title: {
type: 'string',
description: 'The title of the post',
required: true,
},
date: {
type: 'date',
description: 'The date of the post',
required: true,
},
isPublished: {
type: 'boolean',
description: 'Whether the post is published',
required: true,
},
},
computedFields: {
url: {
type: 'string',
resolve: (doc) => `/${doc._raw.flattenedPath}`,
},
author: {
type: 'string',
resolve: () => 'Sean Oliver',
},
},
}))
isPublished
is a boolean indicating whether the post is ready to be
published (more on this below)author
is just hardcoded to my name so I don't need to add it to the
frontmatter in each postisPublished
is consumed by a custom React hook, usePosts
, that returns an
array of all posts when in development
and only the published posts otherwise:
import { useState, useEffect } from 'react'
import { allPosts, type Post } from 'contentlayer/generated'
import { compareDesc } from 'date-fns'
export default function usePosts() {
const [posts, setPosts] = useState<Post[]>()
const sortPosts = (posts: Post[]): Post[] =>
posts.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)))
useEffect(() => {
const filteredPosts =
process.env.NODE_ENV === 'development'
? allPosts
: allPosts.filter((post) => post.isPublished)
setPosts(sortPosts(filteredPosts))
}, [])
return posts
}
This is also the file where you add plugins you want to use in the MDX parser. Here are the ones I'm using:
remark-gfm
- Adds GitHub Flavored
Markdown (e.g. tables, strikethrough, etc.)rehype-pretty-code
- Adds
powerful syntax highlighting to code blocksAnd here they are in context:
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
import remarkGfm from 'remark-gfm'
import rehypePrettyCode from 'rehype-pretty-code'
// ... Post object
/** @type {import('rehype-pretty-code').Options} */
const options = {
theme: 'nord',
}
export default makeSource({
contentDirPath: 'posts',
documentTypes: [Post],
date: { timezone: 'America/Los_Angeles' },
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrettyCode, options]],
},
})
Note: Don't forget to specify your timezone here as well, otherwise your posts will appear to be published a day in the past if you're on the West Coast.
One of the great benefits of Contentlayer and MDX is that you can use all the same components you're using in your app to style your content. For now, I'm just using Tailwind CSS with shadcn/ui's standard typography rules to style the content itself:
import './mdx.css'
import type { MDXComponents } from 'mdx/types'
import UnderLink from '@/components/under-link'
import CodeBlock from '@/components/code-block'
export const mdxComponents: MDXComponents = {
p: (props) => (
<p {...props} className='leading-7 [&:not(:first-child)]:mt-6' />
),
h1: (props) => <h1 {...props} className='text-3xl leading-9 mb-4' />,
h2: (props) => (
<h2
{...props}
className='scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 mt-16'
/>
),
h3: (props) => (
<h3
{...props}
className='scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 mt-16'
/>
),
h4: (props) => (
<h4
{...props}
className='scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 mt-16'
/>
),
h5: (props) => (
<h5 {...props} className='text-md leading-5 mb-4 first:mt-0 mt-16' />
),
h6: (props) => (
<h6 {...props} className='text-sm leading-4 mb-4 first:mt-0 mt-16' />
),
ul: (props) => <ul {...props} className='my-6 ml-6 list-disc [&>li]:mt-2' />,
ol: (props) => <ol {...props} className='list-decimal pl-6 mb-6' />,
li: (props) => <li {...props} className='mb-2 text-sm' />,
a: (props) => <UnderLink target='_blank' {...props} />,
blockquote: (props) => (
<blockquote {...props} className='mt-6 border-l-2 pl-6 italic' />
),
hr: (props) => <hr {...props} className='border-gray-300 my-6' />,
table: (props) => (
<table {...props} className='w-full text-left border-collapse' />
),
th: (props) => (
<th
{...props}
className='px-6 py-3 border-b border-gray-300 text-sm font-semibold text-gray-600'
/>
),
td: (props) => (
<td {...props} className='px-6 py-4 border-b border-gray-300 text-sm' />
),
code: (props) => (
<code
{...props}
className={
!props['data-theme'] &&
'relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono font-semibold'
}
/>
),
pre: (props) => <CodeBlock {...props} />,
}
You can read this object as, "Anytime you render Markdown as HTML, use these components instead of the default ones." For example, if you have a Markdown file that looks like this:
# Hello World
This is a paragraph.
## This is a heading
This is another paragraph.
- This is a list item
- This is another list item
It will be rendered as:
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<h2>This is a heading</h2>
<p>This is another paragraph.</p>
<ul>
<li>This is a list item</li>
<li>This is another list item</li>
</ul>
But if you add an mdxComponents
object like the one above, it will be rendered
as:
<h1 class='text-3xl leading-9 mb-4'>Hello World</h1>
<p class='leading-7 [&:not(:first-child)]:mt-6'>This is a paragraph.</p>
<h2 class='scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 mt-16'>This is a heading</h2>
<p class='leading-7 [&:not(:first-child)]:mt-6'>This is another paragraph.</p>
<ul class='my-6 ml-6 list-disc [&>li]:mt-2'>
<li class='mb-2 text-sm'>This is a list item</li>
<li class='mb-2 text-sm'>This is another list item</li>
</ul>
For even more control over the look and feel, you can also replace the default HTML elements with custom components. I'm using custom components for code blocks and text links:
import './mdx.css'
import type { MDXComponents } from 'mdx/types'
import UnderLink from '@/components/under-link'
import CodeBlock from '@/components/code-block'
export const mdxComponents: MDXComponents = {
// ... other components
a: (props) => <UnderLink target='_blank' {...props} />,
pre: (props) => <CodeBlock {...props} />,
}
Then you can just design your components to take the props you need and render
them however you want. For example, here's my UnderLink
component:
import Link from 'next/link'
export const UnderLink = ({
href,
children,
}: {
href: string
children: React.ReactNode
}): JSX.Element => {
// If the link is an external link, open in new tab
let isExternal = href.startsWith('http') ? true : false
return (
<Link
href={href}
className="font-medium text-primary underline underline-offset-4 hover:no-underline"
target={isExternal ? '_blank' : '_self'}
>
{children}
</Lin
)
}
And the beauty of MDX is that you can embed React components directly in your Markdown files:
# Hello World
This is a paragraph.
## This is a heading
This is another paragraph.
- This is a list item
- This is another list item
<UnderLink href='https://google.com'>This is a link</UnderLink>
I haven't had much use for this yet, but it's great to have the flexibility to create totally embed totally custom components in blog posts when the situation calls for it.
Overall, I'm really happy with the way things shaped up. It's a little more involved than something like Substack or Ghost, but in exchange for a little more work upfront, I get a lot more flexibility and control over the look and feel of my blog. And I'm not locked into a proprietary platform that could disappear at any moment.