How I built a Markdown Rendered Blog using Supabase and Chakra UI

Suraj Vishwakarma - Apr 23 - - Dev Community

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


Enter fullscreen mode Exit fullscreen mode

Use the below setting for the NextJS:

NextJS setting

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


Enter fullscreen mode Exit fullscreen mode

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>
      );
    }


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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);


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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.

Table data

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

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.

Bucket

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.

Row level Security - Supabase

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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;


Enter fullscreen mode Exit fullscreen mode

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:

Home Page

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.


Enter fullscreen mode Exit fullscreen mode

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>;


Enter fullscreen mode Exit fullscreen mode

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>
      );
    };


Enter fullscreen mode Exit fullscreen mode

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;


Enter fullscreen mode Exit fullscreen mode

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.

Create Route

/[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;


Enter fullscreen mode Exit fullscreen mode

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>
      );
    };


Enter fullscreen mode Exit fullscreen mode

Here, you can see the screenshot of a rendered article.

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .