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 configexport 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 Postexcerpt: My first ever post on my blogdate: "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 }) => { r