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
- Writing the blogs in the React and then building the image and deploying the update
- Using a CMS like Butter
- 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
- Adding markdown file support to React, this is much friendlier to write in than pure HTML
- Adding some endpoints so we can upload markdown files and images to our server
- Automating deployment using a python script and Github Actions
Adding markdown support to React
Adding the package
npm i react-markdown
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>
);
}
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;
}
};
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;
};
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())
}
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)
}
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))
}
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)
}
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)
}
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
}
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))
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.
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
{
"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
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
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.
And thats it. Our post is now visible on the website. Thank you for reading.
Top comments (0)