Hello World

Some plans for the blog and how it all works

Welcome to my new blog. I started working on this for fun to see what it's like to build a blog on top of Next.JS. Turns out, it's pretty easy and the end result can be really cool. I'll dive in to the inner workings with the goal of hopefully helping someone else on the same journey.

Goal for the blog

Occasionally I have the urge to write about something. Often it will be about tech or a business idea, but who knows. Now that I've given myself this creative outlet we'll see what I can come up with. No promises on the frequency at which I post on here.

How it works

All blog posts are written in MDX. MDX allows you to use JSX in markdown, enabling much more complex content to be included in markdown. This portion of my site is powered by a few packages:

  • Most notably, the contentlayer package by Contentlayer. It deals with parsing my mdx into beautiful type-safe objects that I can reference to build up the pages you are visiting
  • remark-gfm to enable Github's extended markdown syntax when writing posts
  • rehype-pretty-code for syntax highlighting in code blocks
  • reading-time to calculate average reading times for my posts
  • unist-util-visit to perform some AST transformations for code blocks

Setting up contentlayer

Setting up contentlayer is not too difficult. I followed their tutorial here, and I'd recommend you do the same. Alternatively, you can just install the necessary packages and "borrow" my config here.

I created a single post schema given that I only have one type of post. I've also required that each post have a title, date, and description. These will be used to build the "Post List" page, as well as used when rendering the header for each individual post.

I've also created a few computed fields — these are keys that will be available on every generated Post object from contentlayer.

export 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,
    description: {
      type: 'string',
      description: 'The description of the post',
      required: true,
  computedFields: {
    url: {
      type: 'string',
      resolve: (post) => `/posts/${post._raw.flattenedPath}`,
    path: {
      type: 'string',
      resolve: (post) => post._raw.flattenedPath,
    createdAt: {
      type: 'number',
      resolve: (post) => new Date(post.date).getTime(),
    readingTime: {
      type: 'string',
      resolve: (post) => readingTime(post.body.raw).text,
export default makeSource({
  contentDirPath: 'src/posts',
  documentTypes: [Post],

Enabling syntax highlighting

Getting syntax highlighting working reliably took the longest when setting this blog up. You'll need to add some additional configuration to your contentlayer.config.ts. Inside of your call to makeSource, include the following code:

export default makeSource({
  // ... the rest of the configuration we already set up
  mdx: {
    // Include GitHub markdown plugin
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      // Include rehype syntax highlighting plugin
          // Pass our theme in, or use a default from shiki
          // AST handling for highlighting and copying of empty lines
          onVisitLine(node: any) {
            if (node.children.length === 0) {
              node.children = [{ type: 'text', value: ' ' }];
          onVisitHighlightedLine(node: any) {
          onVisitHighlightedWord(node: any) {
            node.properties.className = ['word--highlighted'];
      // AST transformation to pass raw value to each code block
      () => (tree) => {
        visit(tree, (node) => {
          if (node?.type === 'element' && node?.tagName === 'div') {
            if (!('data-rehype-pretty-code-fragment' in node.properties)) {
            for (const child of node.children) {
              if (child.tagName === 'pre') {
                child.properties.raw = node.raw;

Styling the code blocks

Now that syntax highlighting is working properly, we need to make our code blocks look a little better. We can easily handle this in our global css file, by adding the following properties. Please note that I'm using TailwindCSS, so these properties may differ if you are not using Tailwind.

/* Empty line styling */
div[data-rehype-pretty-code-fragment] code {
  display: grid;
/* Apply styling to code block */
div[data-rehype-pretty-code-fragment] {
  overflow: hidden;
  @apply bg-neutral-800 rounded-sm leading-6 py-0;
/* Apply styling to code block body */
div[data-rehype-pretty-code-fragment] pre {
  overflow-x: scroll;
  @apply bg-neutral-800 px-5 py-0 text-sm leading-7;
/* Apply styling to code block title */
div[data-rehype-pretty-code-title] {
  @apply bg-neutral-700 px-4 py-2 text-sm text-neutral-300 font-medium;

Once you've applied styling to your code blocks, they'll be looking a lot nicer. Let's move on to creating the other primitives we'll need.

Extending contentlayer

Contentlayer allows you to create React components, use them in Markdown and then will render everything as if it were written in a React page.

For this blog, we'll be keeping things pretty basic. I'll start with the following components, and add more as I see fit.

  • Figure, for posting images with captions in a consistent manner.
  • Override the a tag to use next/link for internal links.
const linkClassName = 'text-blue-400 hover:text-blue-500 transition';
export const mdxComponents: MDXComponents = {
  /* Override the `a` tag so when a link is internal,
   * use the Next.js link component
  a: ({ href, children }) => {
    if (!href?.startsWith('/')) {
      return (
          href={href as string}
    return (
      <Link className={linkClassName} href={href as string}>
  /* Custom figure component for images */
  Figure: ({
  }: {
    src: string;
    width: number;
    height: number;
    caption: string;
    className?: string;
  }) => {
    return (
      <figure className="flex flex-col w-full items-center">
          className={clsx('rounded-sm', className)}
        <figcaption className="mt-1.5 text-sm text-neutral-400">

Great, we've enabled syntax highlighting, styled our code blocks, and created the necessary custom components. We're ready to write some content.

Rendering a post

In order for visitors to read the posts you write, you'll need to create a page that fetches a post based on a URL parameter. I've created a page at /posts/[slug]/page.tsx (in the app directory).

This component is pretty straightforward, it's mostly CSS layout and some details. What you should pay attention to is this MDX component. It renders the actual markdown content on the page. We'll explain that next.

// Statically generate blog posts at build-time
export async function generateStaticParams() {
  return allPosts.map((post) => ({
    slug: post.path,
// Generate SEO data for each page
export function generateMetadata({
  params: { slug },
}: Params): Metadata | undefined {
  const post = allPosts.find((p) => p.path === slug);
  if (!post) {
  const { title, description } = post;
  return {
    title: `${title} - Connor Stevens`,
    openGraph: {
      title: `${title} - Connor Stevens`,
      type: 'article',
      url: `https://cnrstvns.dev/posts/${slug}`,
    twitter: {
      card: 'summary_large_image',
// Post component
export default function Post({ params: { slug } }: Params) {
  // Find the post from `allPosts` provided by `contentlayer`
  const post = allPosts.find((p) => p.path === slug);
  if (!post) {
    return notFound();
  return (
    <div className="flex flex-col max-w-4xl mx-auto pt-32 pb-10 min-h-screen px-6 lg:px-32 text-white">
      <div className="flex flex-col items-start space-y-5">
            className="text-center text-neutral-400 hover:text-neutral-300 transition text-sm font-medium uppercase"
            <FontAwesomeIcon icon={faArrowLeft} className="mr-2" />
            go back
        <div className="space-y-1">
          <h1 className="text-3xl font-bold">{post.title}</h1>
          <time dateTime={post.date} className="text-sm text-neutral-500">
            {dayjs(post.createdAt).format('MMMM D, YYYY')}
        <article className="text-white space-y-4 w-full md:w-[unset] prose prose-invert">
          {/* Render our MDX component */}
          <MDX code={post.body.code} />
        <div className="w-full border-t border-neutral-500 py-4 text-neutral-500">
          Thanks for reading. If you enjoyed this post, check back at a later
          date for more new content.

The markdown renderer

We'll create a client component so that we can call the useMDXComponent hook. This hook allows us to transform the JSX we write in our MDX into HTML that our browser can understand. Render this component, and pass in the mdxComponents object that we created earlier.

export function MDX({ code }: MDXProps) {
  const Component = useMDXComponent(code);
  return <Component components={mdxComponents} />;

Writing our first post

Now that we have all the pieces ready, we can write a short demo to show off what we've been working on.

title: Hello World
date: 2023-05-01:00:00:00
description: Some plans for the blog and how it all works.
Hello world. This is a demo post for my first blog post. Here's some text: wow!
text... goes, here?
  caption="The owner of this house"

If all is configured properly, you should be able to visit your post page and view the content you just wrote. If not, you're SOL. Just kidding — try asking ChatGPT. Or, if you're old school, give the old google-fu a go.

Credit where credit is due

Setting this blog up wasn't exactly straight forward. Thanks to the following folks/resources that I referenced while getting this off the ground. Shout-out to Rishaan for toughing it out with me on Discord.

  • Shad's UI docs source here
  • LeeRob's blog source here
  • Contentlayer's getting started guide here
  • Delba's guide on build-time syntax highlighting here
Thanks for reading. If you enjoyed this post, check back at a later date for more new content. If you're interested in how I built this blog, check out the post about it here.
"When they say it can't be done, that's when I get started." - A.P.S.
Copyright 2024 — All rights reserved.