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)