DEV Community

Cover image for Don't pay for a Content Management System. Build your own
Mat
Mat

Posted on • Originally published at cronusmonitoring.com

Don't pay for a Content Management System. Build your own

Building your SEO is vital for any websites success. You should also be building organic traffic to your site before engaging in digital margeting. It can be prohibitively expensive, especially for something you do as a hobby. I also believe you should determine whether there is demand for your website before investing thousands of dollars in advertising.

cronusmonitoring.com is something that I really enjoy working on, without a monetization strategy in place I'd prefer not to pay $500 a day (Google's suggested bidding price) to drive traffic to the site. So I've turned to blogging as a means to improve SEO.

Ok so I've decided to start writing blogs, what approach should I take?

The website is composed of a React Frontend, A Go Server and a MySQL database. All of this is bundled up nicely into a docker image. If you want to use a similar setup, I've got an awesome-template that you can use.

I've considered the following options and at the end decided to build my own. I'll go through each and discuss their merits

  1. Writing the blogs in the React and then building the image and deploying the update
  2. Using a CMS like Butter
  3. Building my own

Writing the blogs in the React and then building the image and deploying the update

I actually starting with this method and have wrote a handful of blog posts. But this became annoying very quickly. I'd write the post, then I'd have to build the docker image and then I'd have to deploy the updated image. I'd often spot typos I've made and then would have to repeat the process. It could take 30 minutes just to upload a single blog post.

Using a CMS like ButterCMS

It was after I had written a few posts that I knew something needed to change. So I did a google search for content management system and saw ButterCMS pop up. It looks fine but the cost is alot for what you get. I could've opted for the Free Developer Plan but if my website is successful one day I would like to monetize it. The next option is $99/month and you only get 50 posts. Seems like a lot in my opinion.

Building my own

After assesing my options I decided that the best approach was to build my own. This ended up being the best approach, as I managed to build a simple CMS in about an hour or 2. The best part is its free.

I'll now be showing how you can build your own.

We'll be doing the following

  1. Adding markdown file support to React, this is much friendlier to write in than pure HTML
  2. Adding some endpoints so we can upload markdown files and images to our server
  3. Automating deployment using a python script and Github Actions

Adding markdown support to React

Adding the package

npm i react-markdown
Enter fullscreen mode Exit fullscreen mode

Adding to a component

function BlogPost({ title, description, slug }) {
  const [data, setData] = useState("");

  useEffect(() => {
    document.title = title;
    meta("description", description);
    return () => {
      document.head.removeChild(metaSelector("description"));
    };
  }, []);

  useEffect(() => {
    getBlog(slug).then((resp) => {
      if (!resp) return;
      setData(resp);
    });
  }, [name]);

  if (data === "") {
    return <h3>Loading...</h3>;
  }

  return (
    <div className={styles.container}>
      <Markdown remarkPlugins={[remarkGfm]}>{data}</Markdown>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fetching the blog posts from the server

Later I'll be showing the implementation of these endpoints. For now I'll show the client using the endpoints

export const getBlog = async (name: string): Promise<string | null> => {
  try {
    const resp = await axios.get(`/api/blog/${name}`);
    if (resp.status === 200) {
      return resp.data;
    }

    return null;
  } catch (error) {
    console.error(error.response?.data.message);
    return null;
  }
};

export interface BlogResponse {
  name: string;
  slug: string;
  description: string;
}

export const listBlogs = async (): Promise<BlogResponse[] | null> => {
  try {
    const resp = await axios.get(`/api/blog`);
    if (resp.status === 200) {
      return resp.data;
    }

    return null;
  } catch (error) {
    console.error(error.response?.data.message);
    return null;
  }
};
Enter fullscreen mode Exit fullscreen mode

Display it all in a Blog page. I've also included a search box

export default function Blog() {
  const [list, setList] = useState<BlogResponse[]>([]);
  const [searchTerm, setSearchTerm] = useState("");

  useEffect(() => {
    listBlogs().then((resp) => {
      if (!resp) return;
      setList(resp);
    });
  }, []);

  const params = useParams();

  const { name } = params;

  const filteredList = list.filter((blog) =>
    blog.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  if (!name) {
    return (
      <div>
        <div className={styles.container}>
          <h1 className={styles.title}> Blogs </h1>
          <input
            type="text"
            placeholder="Search"
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            className={styles.searchInput}
          />
          <ul className={styles.listStyle}>
            {filteredList.map((blog, index) => (
              <BlogPreview
                desc={blog.description}
                name={blog.name}
                slug={blog.slug}
                key={`${blog.name}-${index}`}
              />
            ))}
          </ul>
        </div>
      </div>
    );
  }

  if (!list) return;

  return (
    <>
      <div className={styles.container}>
        <BlogPost
          slug={name}
          title={name ? name : list[getIndexOfSlug(list, name)].name}
          description={
            name ? name : list[getIndexOfSlug(list, name)].description
          }
        />
      </div>
    </>
  );
}

const getIndexOfSlug = (list: BlogResponse[], slug: string) => {
  for (let i = 0; i < list.length; i++) {
    if (list[i].slug === slug) return i;
  }
  return 0;
};
Enter fullscreen mode Exit fullscreen mode

Updating the Server to allow creating blog files and also fetching of posts

I'll omit the authentication code. But you should protect your POST endpoints.

First of all we'll need a schema update for a Blog object. If you're unfamiliar with Prisma for Go I recommend you check out goprisma.org.

model Blog {
    slug        String   @id
    name        String
    description String   @default("")
    createdAt   DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll create an endpoint for creating a blog post.

func RestCreateMD(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB is the max memory size
        responses.Response(w, http.StatusBadRequest, "error parsing form data")
        return
    }

    name := r.FormValue("name")
    desc := r.FormValue("description")
    slug := Slugify(name)


    file, _, err := r.FormFile("content")
    if err != nil {
        responses.Response(w, http.StatusBadRequest, "error retrieving the file")
        return
    }
    defer file.Close()

    // Read the contents of the file
    fileBytes, err := io.ReadAll(file)
    if err != nil {
        responses.Response(w, http.StatusInternalServerError, "error reading file content")
        return
    }

    // Save the blog post details to the database
    client, _ := util.GeneratePrismaClientWithContext()
    ctx := r.Context()
    defer util.CleanupPrismaClient(client)

    _, err = client.Blog.UpsertOne(db.Blog.Slug.Equals(slug)).Create(
        db.Blog.Slug.Set(slug),
        db.Blog.Name.Set(name),
        db.Blog.Description.Set(desc),
    ).Update(
        db.Blog.Slug.Set(slug),
        db.Blog.Name.Set(name),
        db.Blog.Description.Set(desc),
    ).Exec(ctx)

    if err != nil {
        log.Error(err)
        responses.Response(w, http.StatusInternalServerError, "failed to save MD file")
        return
    }

    // Define the path and filename for the MD file
    docPath := os.Getenv("BLOG_DOC_PATH")
    filename := name + ".md"
    filePath := filepath.Join(docPath, filename)

    // Save the MD file
    if err := os.WriteFile(filePath, fileBytes, 0644); err != nil {
        log.Error(err)
        responses.Response(w, http.StatusInternalServerError, "failed to save MD file")
        return
    }

    // Respond with the created blog post details (or just a success message)
    msg := fmt.Sprintf("Blog post '%s' created successfully", name)
    log.Info(msg)
    responses.Response(w, http.StatusCreated, msg)
}
Enter fullscreen mode Exit fullscreen mode

Similarly, We'll create an endpoint for uploading an image.

func RestCreateImage(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseMultipartForm(10 << 20); err != nil { // 32MB is the max memory size
        responses.Response(w, http.StatusBadRequest, "image is too large")
        return
    }
    file, handler, err := r.FormFile("image")

    if err != nil {
        responses.Response(w, http.StatusInternalServerError, "something went wrong")
        return
    }

    defer file.Close()

    imagePath := os.Getenv("BLOG_IMAGES_PATH")
    path := fmt.Sprintf("%s/%s", imagePath, handler.Filename)

    dst, err := os.Create(path)
    if err != nil {
        responses.Response(w, http.StatusInternalServerError, "something went wrong")
        return
    }

    defer dst.Close()

    if _, err = io.Copy(dst, file); err != nil {
        responses.Response(w, http.StatusInternalServerError, "something went wrong")
        return
    }

    responses.Response(w, http.StatusCreated, fmt.Sprintf("%v uploaded successfully", handler.Filename))
}
Enter fullscreen mode Exit fullscreen mode

Now we need some endpoints for fetching the blog posts and images.

Creating an endpoint to fetch a blog post

func RestGetMD(w http.ResponseWriter, r *http.Request) {
    doc := chi.URLParam(r, "doc")

    if doc == "" {
        responses.Response(w, http.StatusNotFound, fmt.Sprintf("%s not found", doc))
        return
    }

    client, _ := util.GeneratePrismaClientWithContext()
    ctx := r.Context()
    defer util.CleanupPrismaClient(client)

    docDB, err := client.Blog.FindUnique(db.Blog.Slug.Equals(doc)).Exec(ctx)
    if err != nil {
        responses.Response(w, http.StatusNotFound, fmt.Sprintf("%s not found", doc))
        return
    }

    doc = docDB.Name + ".md"

    docPath := os.Getenv("BLOG_DOC_PATH")
    path := fmt.Sprintf("%s/%s", docPath, doc)
    http.ServeFile(w, r, path)
}
Enter fullscreen mode Exit fullscreen mode

Creating an endpoint to fetch an image

func RestGetImage(w http.ResponseWriter, r *http.Request) {

    image := chi.URLParam(r, "image")

    if image == "" {
        responses.Response(w, http.StatusBadRequest, "supply an image")
        return
    }

    // regex is to only allow certain files to be requested and protects against mistakes and malicious requests
    re := regexp.MustCompile(`^/([^.]+)\.(svg|ico|jpg|png)$`)
    matches := re.FindStringSubmatch(r.RequestURI)

    if matches == nil {
        responses.Response(w, http.StatusNotFound, fmt.Sprintf("%s not found", image))
        return
    }

    imagePath := os.Getenv("BLOG_IMAGES_PATH")
    path := fmt.Sprintf("%s/%s", imagePath, image)

    http.ServeFile(w, r, path)

}
Enter fullscreen mode Exit fullscreen mode

Finally we'll need an endpoint to list avaliable blog posts

func RestListMD(w http.ResponseWriter, r *http.Request) {

    client, _ := util.GeneratePrismaClientWithContext()
    ctx := r.Context()
    defer util.CleanupPrismaClient(client)

    files, err := listMarkdownFiles(client, ctx)
    if err != nil {
        log.Errorf("failed to list md files %v", err)
        responses.Response(w, http.StatusInternalServerError, "something went wrong")
        return
    }

    b, _ := json.Marshal(files)

    responses.BuildableResponse(w, http.StatusOK, b)
}

func listMarkdownFiles(client *db.PrismaClient, ctx context.Context) ([]BlogResponse, error) {
    filenames := make([]BlogResponse, 0)

    docs, err := client.Blog.FindMany().Exec(ctx)
    if err != nil {
        return nil, err
    }

    for _, doc := range docs {
        filenames = append(filenames, BlogResponse{
            Slug:        doc.Slug,
            Name:        doc.Name,
            Description: doc.Description,
        })
    }

    return filenames, nil
}
Enter fullscreen mode Exit fullscreen mode

Great, all our functions are setup. All thats left to do is implement them

// Content Management (Blog)
mux.HandleFunc("/api/blog/image/{image}", cms.RestGetImage)
mux.Get("/api/blog/{doc}", cms.RestGetMD)
mux.Get("/api/blog", cms.RestListMD)
mux.Post("/api/blog/image", auth.PriviledgedAuth(cms.RestCreateImage))
mux.Post("/api/blog", auth.PriviledgedAuth(cms.RestCreateMD))
Enter fullscreen mode Exit fullscreen mode

Automating deployment using a python script and Github Actions

This is how I have my blog folder setup. I have a docs directory that contains folders that each have a meta.json file and a .md file.

CMS setup in a Go React Server

By the way. The above image is displayed using

![CMS setup in a Go React Server](/api/blog/image/cms-setup.png)
Enter fullscreen mode Exit fullscreen mode

Here's what my meta.json looks like. For now I'm just supplying a description

{
  "description": "Setting up a Content Management System (CMS) is simple. Build your own for free using Go. Improve your workflow and upload new blogs automatically."
}
Enter fullscreen mode Exit fullscreen mode

Next we'll create a simple python script that will iterate through the folder we have created and upload the assets

import json
import os

import requests


URL = "https://cronusmonitoring.com"
KEY = "<INSERT_KEY>"


def main():
    print("uploading blog to Cronus")
    print("Uploading images...")
    upload_images(f'{os.getenv("BLOG_PATH")}/images')
    print("Uploading blog posts...")
    upload_blogs(f'{os.getenv("BLOG_PATH")}/docs')

def upload_blogs(directory):
    for root, dirs, files in os.walk(directory):
        for file in files:
            if file != "meta.json":
                meta_path = os.path.join(root, "meta.json")
                md_path = os.path.join(root, file)
                if os.path.exists(meta_path):
                    with open(meta_path, "r") as meta_file:
                        obj = json.load(meta_file)
                        upload_file(md_path, "blog", obj["description"])


def upload_images(directory):
    for root, dirs, files in os.walk(directory):
        for file in files:
            file_path = os.path.join(root, file)
            upload_file(file_path, "image", "")


def upload_file(file_path, file_type, description):
    # Determine the correct endpoint and field name based on file type
    url = f"{URL}/api/blog"
    if file_type == "image":
        url = f"{url}/image"
        files = {"image": open(file_path, "rb")}
    else:  # Assuming file_type is "blog"
        files = {"content": open(file_path, "rb")}

    # Common data for both requests
    data = {
        "name": os.path.basename(file_path).split(".")[0],
        "description": description,
    }
    headers = {"Authorization": f"Bearer {KEY}"}

    response = requests.post(url, files=files, data=data, headers=headers)

    if response.status_code == 201:
        print(f"Successfully uploaded {file_path}")
    else:
        print(f"Failed to upload {file_path}. Status code: {response.status_code}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Next we'll create a Github Action workflow, so that any time we push a change to the repo, the blog is updated on the website

name: Run Upload Script

on:
  push:
    branches:
      - main
    paths:
      - "blog/**/*"

jobs:
  run-script:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Run upload.py
        env:
          BLOG_PATH: blog
        run: python blog/upload.py
Enter fullscreen mode Exit fullscreen mode

Lets commit our code to the repo and see our blog appear on the website.

running github actions to automatically deploy a blog

And thats it. Our post is now visible on the website. Thank you for reading.

Viewing Blog Post on Cronus using a custom CMS

Top comments (0)