Introduction
I was building a project for myself where I needed to build a blog. As a technical writer, I have always written markdown articles once I understand them. Rather than using a text formatter, I wanted to build a blog that renders the Markdown. In this article, I'll walk you through the process of building a Markdown-rendered blog using Supabase and Chakra UI.
Supabase will be used for storing article data in the database and the cover image of the article in storage. Chakra UI will be used to provide style to the elements. By using both, we can build the blog with ease.
In the project, we are going to create 3 routes:
- /(root): It will display all the articles in the grid format.
- /create: It will be used to create an article by providing a name, description, and markdown version of the article.
- /[slug]: It will render the markdown article.
Now, let’s get started building the project.
Setting up the Environment
Let’s set the environment for building the project. As for the frontend framework, we are going to use NextJS with the app directory. It will be helpful in routing and navigation. Install the NextJS project with the below command:
npx create-next-app@latest supabase-blog
Use the below setting for the NextJS:
Note: To run the above command, You need to have NodeJS and NPM pre-installed on your system.
Once the NextJS project is installed, we can now install the dependencies.
ChakraUI
In the root directory of the project run the below command to install the Chakra UI.
npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion chakra-react-select react-toastify
I have additionally added chakra-react-select
and react-toastify
for improving the dropdown select and notifying the user about the response respectively. After installation, we need to update the layout.js
from the app directory. Here is the code:
"use client";
import { Inter } from "next/font/google";
import "./globals.css";
import { ChakraProvider } from "@chakra-ui/react";
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import theme from "../theme/theme";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>
<ChakraProvider theme={theme}>
<ToastContainer />
{children}
</ChakraProvider>
</body>
</html>
);
}
We have used the “use client” at the top of the file to do Client Side Rendering for Chakra UI. Other than is self-explanatory. The theme
which is imported is the Customized theme of the Charka UI component. You can look here for all the customized components.
Supabase
Let’s set up the Supabse in our project. Install the Supabase client for JavaScript with the following command:
npm i npm install @supabase/supabase-js
Now, we need to create a project in the Supabase dashboard for accessing the API key. Visit Supabse and create an account. Additionally, you can refer to the Supabase section of this article for creating a project.
Now, create a lib
directory in the root for putting the supabase client here.
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON;
export const supabase = createClient(supabaseUrl, anonKey);
SUPABASE_URL
and SUPABASE_ANON
are secrets that can be found in the Supabase App dashboard. Go to Project Setting → API, you will find it there. You also need to create the .env
file in the root for storing the env.
NEXT_PUBLIC_SUPABASE_URL=<SUPABASE_URL>
NEXT_PUBLIC_SUPABASE_ANON=<SUPABASE_ANON>
We need to create a database table and storage in Supabse for storing article data and cover images respectively. Create a table from Table Editor
with the following table columns.
Make sure tags are taken as an array. You can do that by clicking the setting in front of tags and selecting Define as Array. Also, deselect the Row Level Security.
Row-level security is useful in making database requests directly from the client. If enabled only specified users are allowed to access the data as per policy. It can help build a serverless SaaS.
From storage create a bucket with the thumbnail
name and make sure the Public bucket is enabled.
One more thing, select Policies from Configuration in Storage. From here Click New Policy
in front t of the thumbnail and then select For Full customization
. Enable SELECT
and INSERT
operations and give a name as shown below. Now, click on Review and then Save Policy.
Now, the Supabase setup is completed.
Additional dependencies
We need two more dependencies to complete the setup. You can install it with the below command:
npm i chakra-ui-markdown-renderer formik
The first one will be used to convert the markdown syntax into an HTML component with the desired Chakra UI theme. Formik for handling the form input for article data.
Project Structure
As discussed above we are going to have 3 routes. Here is the structure in the app directory.
--app
--page.js
--create
--page.js
--[slug]
--page.js
Let’s first start with the root directory.
/(root)
In the root, we are going to display the article. Here is the code for the file.
// Import is not added for all the code files. You can get the whole code from the GitHub repo mentioned below.
const BlogTags = (props) => {
const { marginTop = 0, tags } = props;
return (
<HStack spacing={2} marginTop={marginTop}>
{tags.map((tag) => {
return (
<Tag size={"md"} variant="solid" colorScheme="orange" key={tag}>
{tag}
</Tag>
);
})}
</HStack>
);
};
const ArticleList = () => {
const [articleData, setArticleData] = useState();
const getArticleData = async () => {
const { data: articleData, error: artilceError } = await supabase
.from("article")
.select();
if (artilceError) {
console.log(artilceError);
} else {
setArticleData(articleData);
console.log(articleData);
}
};
useEffect(() => {
getArticleData();
}, []);
return (
<Box marginTop="20" className="mainContainer">
<Heading variant="primary-heading" mt={20}>
Stories by Suraj Vishwakarma
</Heading>
<Flex justifyContent="space-between">
<Heading variant="secondary-heading" marginTop="10" marginBottom="10">
Latest articles
</Heading>
<Link href="/create">
<Button variant="primary-button">Create</Button>
</Link>
</Flex>
<SimpleGrid
templateColumns={{
base: "repeat(1, 1fr)",
lg: "repeat(6, 1fr)",
}}
spacing="40px"
>
{!articleData &&
[0, 1, 2].map((item) => {
return (
<GridItem colSpan={{ base: 6, lg: 2 }} key={item}>
<Wrap spacing="30px" marginBottom="10">
<WrapItem
width={{ base: "100%", sm: "100%", md: "100%", lg: "100%" }}
>
<Box w="100%" height="100%">
<Box borderRadius="lg" overflow="hidden">
<Box
textDecoration="none"
_hover={{ textDecoration: "none" }}
>
<Skeleton colorScheme="purple">
<div
style={{ borderRadius: "10px", height: "400px" }}
/>
</Skeleton>
</Box>
</Box>
</Box>
</WrapItem>
</Wrap>
</GridItem>
);
})}
{articleData &&
articleData.map((item, index) => {
return (
<GridItem colSpan={{ base: 6, lg: 2 }} key={index}>
<Wrap spacing="30px" marginBottom="10">
<WrapItem
width={{ base: "100%", sm: "100%", md: "100%", lg: "100%" }}
>
<Box w="100%" height="100%">
<Box borderRadius="lg" overflow="hidden">
<Box
textDecoration="none"
_hover={{ textDecoration: "none" }}
>
<Link href={`/${item.slug}`}>
<Image
transform="scale(1.0)"
src={item.thumbnail}
alt="some text"
objectFit="contain"
width="100%"
transition="0.3s ease-in-out"
_hover={{
transform: "scale(1.05)",
}}
/>
</Link>
</Box>
</Box>
<BlogTags tags={item.tags} marginTop={3} />
<Heading fontSize="xl" marginTop="2">
<Link href={`/${item.slug}`}>
<Text
textDecoration="none"
_hover={{ textDecoration: "none" }}
>
{item.title}
</Text>
</Link>
</Heading>
<Text as="p" fontSize="md" marginTop="2">
{item.description}
</Text>
</Box>
</WrapItem>
</Wrap>
</GridItem>
);
})}
</SimpleGrid>
</Box>
);
};
export default ArticleList;
We are here using the useEffect hook to call the getArticle()
function that fetches the article data. In the return section, we are displaying the article. On completion, this route will look like this:
Create Route
Create a directory in the app directory with the name create. This route will be useful in creating the article. Here we have 4 files.
--page.js // page will be rendered.
--ArticleSetting.js // it will contain data such as title, description, tags, etc.
--WriteArticle.js // it used to put the markdown version of the article.
--tagOption.js // this file contain the tag option that can be selected.
All the files are quite big in terms of lines of code. So rather than putting it all here. You can look into it from the GitHub repository here. Some interesting pieces of code that we can discuss from these routes are:
Uploading Image to Storage
<FormControl>
<Input
variant={"form-input-file"}
name="thumbnail"
type="file"
onChange={async (e) => {
const timestamp = Date.now();
const { data, error } = await supabase.storage
.from("thumbnail")
.upload(`${timestamp}-${e.target.files[0].name}`, e.target.files[0], {
cacheControl: "3600",
upsert: false,
});
if (error) {
console.log(error);
return;
}
const path = data.path.replace(/ /g, "%20");
const SUPABASE_REFERENCE = "hkvyihwfkphuwgetdtee";
const URL = `https://${SUPABASE_REFERENCE}.supabase.co/storage/v1/object/public/thumbnail/${path}`;
setImgURL(URL);
setFieldValue("thumbnail", URL);
}}
onBlur={handleBlur}
/>
</FormControl>;
Here you see, we have used the Input component from Chakra UI to get the image and on every change, we are uploading the image to the bucket in the Supabase. You can get SUPABASE_REFERENCE
from the Dashboard→ Setting from the Supabase.
Rendering Markdown
const PreviewArticle = ({ contentMarkdown }) => {
return (
<Box>
{/* @ts-ignore */}
<Markdown components={ChakraUIRenderer(MarkdownTheme)} skipHtml>
{contentMarkdown}
</Markdown>
</Box>
);
};
This is in the WriteArticle.js
It is used to render the Markdown into HTML element. The MarkdownTheme
is provided in the theme directory in the route. Here is the code for it.
import { Text, Heading, Link, Code, ListItem, UnorderedList } from '@chakra-ui/react';
const MarkdownTheme = {
p: (props) => {
const { children } = props;
return (
<Text variant={"secondary-text"} lineHeight={2} p="0.5em 0">
{children}
</Text>
);
},
h1: (props) => {
const { children } = props;
return (
<Heading variant={"secondary-heading"} lineHeight={2}>
{children}
</Heading>
);
},
h2: (props) => {
const { children } = props;
return (
<Heading variant={"secondary-heading"} lineHeight={2}>
{children}
</Heading>
);
},
a: (props) => {
const { href, children } = props;
return (
<Link href={href} variant={"secondary-text"} textDecoration="underline" cursor="pointer" fontWeight="bold" lineHeight={2}>{children}</Link>
);
},
code: (props) => {
const { children } = props;
// console.log("props", props)
console.log(children.includes("\n"))
if(children.includes("\n")){
return (
<Code children={children} colorScheme='purple' width="100%" p="1em 1em"/>
);
}else{
return (
<a style={{backgroundColor:"lightgray", padding:"0 0.2em"}}>{children}</a>
);
}
},
li: (props) => {
const { children } = props;
return (
<UnorderedList>
<ListItem><Text variant={"secondary-text"} lineHeight={2}>
{children}
</Text></ListItem>
</UnorderedList>
);
},
};
export default MarkdownTheme;
In the above code, you can see how each HTML tag will be rendered as per the Chakra theme. You can refer to Basic Syntax Guide by MarkdownGuide to understand which Syntax is mapped to which HTML tag.
Overall here is the gif demonstrating this route functionality.
/[slug]
This route is used to render the blog as an HTML page that can be viewed. Here are the two files: page.js
will render a skeleton when loading and if the article is found then will display the article using the DisplayArticle.jsx
component. Here is the code.
page.js
const ArticlePage = () => {
const [articleData, setArticleData] = useState(null);
const router = useRouter();
const pathname = usePathname();
const handleFetchParam = async () => {
const slugArr = pathname.split("/");
const slug = slugArr[slugArr.length - 1];
console.log(slug);
const { data: articleData, error: artilceError } = await supabase
.from("article")
.select()
.eq("slug", slug);
if (artilceError) {
console.log(artilceError);
} else {
setArticleData(articleData[0]);
console.log(articleData[0]);
}
};
useEffect(() => {
handleFetchParam();
}, []);
return (
<Box margin="0 auto">
{!articleData && (
<Stack
spacing={10}
margin="0 auto"
marginTop={20}
marginBottom={20}
width={{
xl: "60%",
"2xl": "50%",
lg: "70%",
base: "100%",
md: "80%",
}}
>
{/* @ts-ignore */}
<Skeleton colorScheme="purple">
<div style={{ borderRadius: "10px", height: "300px" }} />
</Skeleton>
<Stack>
<Skeleton colorScheme="purple">
<div style={{ borderRadius: "10px", height: "100px" }} />
</Skeleton>
<Skeleton colorScheme="purple">
<div style={{ borderRadius: "10px", height: "40px" }} />
</Skeleton>
<Skeleton colorScheme="purple">
<div style={{ borderRadius: "10px", height: "100px" }} />
</Skeleton>
</Stack>
<Box>
<Skeleton colorScheme="purple">
<div style={{ borderRadius: "10px", height: "600px" }} />
</Skeleton>
</Box>
</Stack>
)}
<Box
width={{ xl: "60%", "2xl": "50%", lg: "70%", base: "100%", md: "80%" }}
margin="0 auto"
marginBottom={40}
>
{articleData != null && <DisplayArticle articleData={articleData} />}
</Box>
</Box>
);
};
export default ArticlePage;
The code is self-explanatory we are just rendering the loading skeleton then on getting the articleData
, we are rendering the DisplayArticle component.
DisplayArticle.jsx
export const DisplayArticle = ({ articleData }) => {
console.log(articleData.markdown);
return (
<Stack width="80%" className="mainContainer" spacing={10} marginBottom={20}>
{/* @ts-ignore */}
<Image
transform="scale(1.0)"
src={articleData.thumbnail}
alt="some text"
objectFit="contain"
width="100%"
mt={20}
/>
<Stack>
<Heading variant="primary-variant">{articleData.title}</Heading>
<BlogTags tags={articleData.tags} marginTop={3} />
</Stack>
<Box>
<Markdown components={ChakraUIRenderer(MarkdownTheme)} skipHtml>
{articleData.markdown}
</Markdown>
</Box>
</Stack>
);
};
const BlogAuthor = (props) => {
return (
<HStack marginTop="2" spacing="2" display="flex" alignItems="center">
<Image
borderRadius="full"
boxSize="40px"
src={props.image}
alt={`Avatar of ${props.name}`}
/>
<Text variant="secondary-text">{props.name}</Text>
<Text>—</Text>
<Text variant="secondary-text">{props.date}</Text>
</HStack>
);
};
const BlogTags = (props) => {
const { marginTop = 0, tags } = props;
return (
<HStack spacing={2} marginTop={marginTop}>
{tags.map((tag) => {
return (
<Tag size={"md"} variant="solid" colorScheme="orange" key={tag}>
{tag}
</Tag>
);
})}
</HStack>
);
};
Here, you can see the screenshot of a rendered article.
GitHub Repository
I have created the GitHub repository of the file. You can refer to that for the whole code at once if you want to recreate the project.
Here is the link: https://github.com/surajondev/supabase-blog
Conclusion
Building a Markdown-rendered blog with Supabase and Chakra UI offers an efficient way to create a dynamic, database-backed blogging platform. By combining Supabase for data storage and file handling with Chakra UI for stylish and responsive UI components, developers can quickly develop a feature-rich blog.
This walkthrough demonstrates setting up routes, CRUD operations, markdown rendering, and image uploads using Next.js for enhanced routing and navigation. The GitHub repository provides a comprehensive guide for replicating and extending this project, enabling developers to customize and expand their own Markdown-powered blogs with ease, tailored to their specific needs and design preferences. This project serves as a practical example of integrating modern technologies to build a scalable and visually appealing blogging platform for content creators.
I hope this article has helped in understanding how to create a blog. Thanks for reading the article.