Don't pay for a Content Management System. Build your own
Tags: webdev, go, react, automation
Published: 2024-02-29 09:48:46

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](https://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](https://github.com/mperkins808/awesome-templates/tree/main/react-go) 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](https://buttercms.com/)
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](https://buttercms.com/)

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

```bash
npm i react-markdown
```

Adding to a component

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

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

```ts
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;
  }
};
```

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

```ts
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;
};
```

### 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](https://goprisma.org/).

```
model Blog {
    slug        String   @id
    name        String
    description String   @default("")
    createdAt   DateTime @default(now())
}
```

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

```go
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)
}
```

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

```go
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))
}
```

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

Creating an endpoint to fetch a blog post

```go
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)
}
```

Creating an endpoint to fetch an image

```go
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)

}
```

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

```go
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
}
```

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

```go
// 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))
```

### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xi6sr6jv3h1kdyokbkz0.png)


By the way. The above image is displayed using

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

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

```json
{
  "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."
}
```

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

```python
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()
```

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

```yaml
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
```

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

![running github actions to automatically deploy a blog](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d3m82yoy6mnqkyh11mwk.png)

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


![Viewing Blog Post on Cronus using a custom CMS](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/khfux2vmc63rdlr6un4x.png)