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
- Define the schema of our Post and where the content lives
 - 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.
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 :)
              

    
Top comments (0)