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)