Getting hundreds on Lighthouse score is not that hard anymore.
Jan Matas
Published on 11/Sep/2023 | 7 min read
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:
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.
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:
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.
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'.
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
.
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,
};
}
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.
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!
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.
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