UPRIGHTFLOW BLOG

Product

SEO optimized blog with Next13, Chakra and MDX

Getting hundreds on Lighthouse score is not that hard anymore.

Avatar of Jan Matas

Jan Matas

Published on 11/Sep/2023 | 7 min read

Screenshot

When I started working on UprightFlow for the first time, I never imagined I would be spending time on creating a blog from scratch. However, I cloud not find an "off-the-shelf" solution that is performant, allows own branding and is cheap.

The blog you are reading now is what I came up with. It has some super neat features:

Lighthouse

I am open-sourcing the template. If you are an indie hacker with Chakra in your stack, you might find it useful. I invite you to skip ahead and look at repo / live demo.

This tutorial finishes with a visually much simpler blog but with most of the functionality. I recommend you to follow the tutorial until the end to get the understanding of the underlying tech and then clone the repo to get the nicer design.

Setup

We are going to be using Next13 with app router. Easiest way is to use create-next-app:

npx create-next-app@latest chakra-next-blog

I went with those settings: Create next app settings

Now we need to setup Chakra in our project. I am pretty much following an amazing [official guide][https://chakra-ui.com/getting-started/nextjs-guide] on Chakra website. First, let's install dependencies:

npm i @chakra-ui/react @chakra-ui/next-js @emotion/react @emotion/styled framer-motion

Now, we need to create new file app/Providers.tsx to wrap our application with ChakraProvider and also CacheProvider to ensure chakra styles are included in server side rendered response.

// app/providers.tsx "use client"; import { CacheProvider } from "@chakra-ui/next-js"; import { ChakraProvider } from "@chakra-ui/react"; export function Providers({ children }: { children: React.ReactNode }) { return ( <CacheProvider> <ChakraProvider>{children}</ChakraProvider> </CacheProvider>); }

Now we just wrap our app with Providers in app/layout.tsx and we are ready to start:

// app/layout.tsx import { Providers } from "./providers"; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html>); }

Now just run npm run dev in your project folder and you should see a working starter template.

Using Chakra with Next13

You might have noticed a mysterious 'use client' directive on top of our app/providers. Chakra does not work with server components, so we need to instruct next to only render it as a client-side component.

Fortunately, this has nothing to do with server side rendering (SSR). Even client side components are rendered on server, but they need to be hydrated on the client side. This means that next needs to include their JS code and ship it to the browser. We will deal with this later.

However, it becomes painful to deal with 'use client' in each component that uses Chakra, so we are just going to work around by reexporting entire Chakra from a file with 'use client' directive:

// app/chakra.ts "use client"; export * from "@chakra-ui/react";

From now on, we will import Chakra components from this file instead of '@chakra-ui/react'.

Writing posts

All posts on our blog are going to be written in MDX. It is a language similar to markdown, but it allows us to import JSX components if needed. We are also going to include a header that contains basic metadata about each post.

Let's go ahead and create app/posts/first-post.mdx:

--- title: "My first post" excerpt: Blogging is awesome. ---Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam interdum justo quis justo placerat dignissim eget non diam. Donec sit amet laoreet tellus. Curabitur eu dui volutpat, lobortis ipsum eu, porttitor lectus. In id efficitur libero. Vivamus purus tortor, blandit ut erat eu, semper dignissim enim. Vivamus lacinia mi eros, sit amet venenatis lorem mollis bibendum.

Please also create a second one that looks similar into app/posts/other-post.mdx.

Parsing posts

We made it easy for blog author to write posts in markdown, but now we need to parse them back into our javascript code. We are going to use a great library called gray-matter that parses the metadata in MDX header (such as the title or author).

npm i gray-matter

The posts should be easily accessible from many parts of the codebase, so we are going to parse them in a utils file.

// app/utils.ts import fs from "fs"; import path from "path"; import matter from "gray-matter"; export interface IPost { slug: string; title: string; excerpt: string; filename: string; content: string; } // Where are all posts located const BLOG_DIR = path.join(process.cwd(), "./app/posts"); function filenameToSlug(filename: string) { // We assume that each post files has '.mdx' extension with 4 characters return filename.slice(0, filename.length - 4); } export function getPosts(): IPost[] { const files = fs.readdirSync(BLOG_DIR); return files.map((filename) => { const slug = filenameToSlug(filename); return getPost(slug); }); } export function getPost(slug: string): IPost { const filename = `${slug}.mdx`; const fileContent = fs.readFileSync(path.join(BLOG_DIR, filename), "utf-8"); const parsedPost = matter(fileContent); const frontMatter = parsedPost.data; const content = parsedPost.content; return { slug, title: frontMatter.title, excerpt: frontMatter.excerpt, filename: filename, content: content, }; }

Post pages

We can finally get some fruit from our hard work and generate blog pages. As I mentioned in the title, this is happening at build time using dynamic route segments and generateStaticParams.

In short, dynamic route segments are folders in your directory structure wrapped by square brackets. At build time, next calls generateStaticParams function in app/[slug]/page.tsx file and creates a route for each entry returned by the function. This is great for blog, because we can create a new route for each file in the app/posts folder! Let's write app/[slug]/page.tsx

// app/[slug]/page.tsx import { Container, Heading, VStack } from "../chakra"; import { IPost, getPost, getPosts } from "../utils"; interface IProps { params: { slug: string }; } // Return a list of `params` to populate the [slug] dynamic segment export function generateStaticParams() { return getPosts().map((post) => { return { slug: post.slug }; }); } function PostContent(props: { post: IPost }) { // TODO return ""; } // Multiple versions of this page will be statically generated // using the `params` returned by `generateStaticParams` export default function Page({ params }: IProps) { const post = getPost(`${params.slug}`); return ( <Container py={20} maxW={"3xl"}> <Heading as="h1" fontSize={"xl"}> {post.title} </Heading> <PostContent post={post} /> </Container>); }

If you now navigate http://localhost:3000/first-post or http://localhost:3000/other-post, you should see that all the routes were rendered.

Rendering markdown

You have probably noticed that the post pages do not have any content yet and PostContent method is still empty. next-mdx-remote package will deal with rendering markdown for us:

npm i next-mdx-remote

The implementation of rendering is pretty simple. We just pass the content of our post (everything apart from the header) to <MDXRemote>.

However, we are using Chakra instead of plain html components. Luckily, MDXRemote lets us override the component implementation so we can render chakra <Text> instead of vanilla paragraph p:

// app/[slug]/page.tsx import { MDXRemote } from "next-mdx-remote/rsc"; import { MDXComponents } from "mdx/types"; import { Text } from "../chakra"; // ... Rest of file ... const components: MDXComponents = { p: ({ children }) => ( <Text mt={4} fontSize={"xl"} fontFamily={"serif"}> {children} </Text> ), }; function PostContent(props: { post: IPost }) { return <MDXRemote source={props.post.content} components={components} />; } // ... Rest of file ...

If you now visit http://localhost:3000/first-post, you should see your blog post!

Home page

We are almost done! Last we thing to complete the blog is the homepage with listing of all blog posts. We are going to use out getPosts util method to fetch all posts and then create a PostPreview card for each of them.

// app/page.tsx import { Card, Container, HStack, Heading, LinkBox, LinkOverlay, Text, VStack, } from "./chakra"; import { IPost, getPosts } from "./utils"; function PostPreview(props: { post: IPost }) { return ( <LinkBox> <Card p={5}> <Heading>{props.post.title}</Heading> <LinkOverlay href={`/${props.post.slug}`}> <Text>{props.post.excerpt}</Text> </LinkOverlay> </Card> </LinkBox> ); } export default function Home() { const posts = getPosts(); return ( <Container py={20} maxW={"3xl"}> <VStack justifyContent={"center"}> <Heading as="h1" fontSize={"xl"}> Chakra + Next13 + MDX blog </Heading> {posts.map((p, i) => ( <PostPreview key={i} post={p} /> ))} </VStack> </Container>); }

Note that each PostPreview is a LinkBox (semantically correct way to create a link from multiple components). The link is pointint to /${props.post.slug} - the route we generated in the last step.

Going further

This is where we end the tutorial. We have managed to create a working performant blog generated from our .mdx files at build time. However, we are not done yet. Head over to the repository where you can follow a few more commits that add:

© 2023 UprightFlow. All rights reserved