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