After getting fed up with React, SPAs, and Javascript around 2021, I decided to re-write my personal webpage in Rust and wrote an article on how you could build a simple blog purely using Rust. It ended up becoming one of my most popular articles, and for good reason: Rust is exciting, fun to write, and blazingly fast. After a while, though, I started to feel frustrated with the development process for adding new features to my site: the feedback loop was simply too long. I've always been interested in solo entrepreneurship and technology. But, as I'm getting older, I realize that I might have been more interested in trying out new technologies. Great for learning and growing as an engineer, bad for shipping projects, and starting to see that sweet MRR grow. I decided to re-re-write my site once again, this time in Go, for multiple reasons: I write Go for a living. Simple language, with a decent type system and fast performance Blazingly fast compile times In this post, I will show how you can create your own personal blog using Go. I'll assume you're familiar with Go and know how to configure a router/database/server, and so on. Should you not, feel free to grab a clone of my Go starter template, Grafto, which has lots of things configured for you out of the box. Foundations I write all my stuff in markdown; if you're a developer who also wants to start blogging, chances are you also are quite familiar with it. After Go 1.16, where we got the embed package included in the standard library, most of our work was already done. We basically only need to have a way of storing some filenames, associating them with an ID or a slug, grabbing the file, and serving it to the user. Pretty simple. Whether you've created your own setup or grabbed a copy of Grafto, create a new directory in the root of your project called posts and in there, create a file called posts.go. Open it and add the following: 1package posts 2 3// imports omitted 4 5//go:embed *.md 6var Assets embed.FS Now, any file with the .md extension will get included in the binary that ultimately gets built once we run go run build. We can simply grab the files from our global Assets variable using Assets.ReadFile(name-of-file) to handle any error that might occur or return the file as a string, e.g.: 1file, err := Assets.ReadFile("my-post.md") 2if err != nil { 3 return err 4} 5 6return string(file) It won't be pretty (we'll fix that later), but it gets the basic idea out, which we can build on. We have a way to include our blog posts in the binary; let's add a way to associate them with a slug. You could use an ID here, but it just looks better to have URLs like: "https://acme.com/my-first-blog-post" compared to "http://acme.com/44a2530b-567d-472f-9495-e2ee64e7ae6d". So, assuming you have a database up and running, add this table: 1create table posts ( 2 id uuid primary key, 3 created_at timestamp not null, 4 updated_at timestamp not null, 5 title varchar(255) not null, 6 filename varchar(255) not null, 7 slug varchar(255) not null 8); Not much exciting going on here. Basically, for the unaware readers, the slug column above will be the title but URL-friendly. So if you have a post with the title "My First Blog Post," the slug equivalent would be "my-first-blog-post" easy. Lastly, we need to be able to serve this to readers. That flow would typically involve them hitting a landing page showing a list of articles they can choose from, which links to the article. The implementation of this will depend a bit on your setup, but let us implement the handler to deal with grabbing a specific article using Echo as our router. Assuming you have a route like /posts/:slug, create the following: 1type ArticleStorage interface { 2 GetPostBySlug(slug string) (Post, error) 3} 4 5func ArticleHandler(ctx echo.Context, storage ArticleStorage) error { 6 postSlug := ctx.Param("postSlug") 7 8 postModel, err := storage.GetPostBySlug(postSlug) 9 if err != nil { 10 return err 11 } 12 13 postContent, err := posts.Assets.ReadFile(postModel.Filename) 14 if err != nil { 15 return err 16 } 17 18 return ctx.String(http.StatusOK, string(postContent)) 19} I'm putting some decisions on your part here in terms of implementing ArticleStorage. We just need something that grabs the data from the DB on the post based on the slug. This is the foundation of what we need...but it's not pretty. Let's fix that by letting the server do what it was always supposed to do: return HTML. Enter templ If you've spent time in the Go ecosystem, chances are you've probably heard about templ. It lets you write HTML templates as Go packages, and it's such a pleasant way of building out UI. Add some HTMX and alpine.js, and you'll get at least 95% of what you get with SPAs, with the added complexity. It's good practice to have a base template that wraps around your other templates so we have a single point for adding things like stylesheets, javascript, metadata, etc. Create a directory in root called views and add the following to a file called base.templ. 1package views 2 3templ base() { 4 <!DOCTYPE html> 5 <html lang="en"> 6 <nav> 7 <a href="/">MBV Labs</a> 8 </nav> 9 <body> 10 { children... } 11 <footer> 12 <aside> 13 <p>Copyright ©2024 </p> 14 <p>All right reserved by MBV Labs</p> 15 </aside> 16 </footer> 17 </body> 18 </html> 19} For this to work, you'll need to install templ and run templ generate which will produce a file called base_templ.go that we can then import into other templates to wrap around them. For the sake of brevity, we'll only create the template to show the actual article. Create a file called article.templ, and add the following: 1type ArticlePageData struct { 2 Title string 3 Content string 4} 5 6templ ArticlePage(data ArticlePageData) { 7 @layouts.Base() { 8 <div> 9 <div> 10 <h1>{ data.Title }</h1> 11 </div> 12 <article> 13 @unsafe(data.Content) 14 </article> 15 </div> 16 } 17} and run templ generate once again. We can now go back and update our ArticleHandler handler: 1func ArticleHandler(ctx echo.Context, storage ArticleStorage) error { 2 postSlug := ctx.Param("postSlug") 3 4 postModel, err := storage.GetPostBySlug(postSlug) 5 if err != nil { 6 return err 7 } 8 9 postContent, err := posts.Assets.ReadFile(postModel.Filename) 10 if err != nil { 11 return err 12 } 13 14 return views.ArticlePage( 15 views.ArticlePagedata{ 16 Title: postModel.Title, 17 Content: postContent, 18 }, 19 ).Render( 20 ctx.Request().Context(), 21 ctx.Response().Writer, 22 ) 23} If you run your application now and visit a valid URL, you should see a (rather ugly) page showing the markdown of your article, but this time, it should have some sweet hypertext markup. Making things (slightly) less ugly In terms of styling things, throwing some tailwind or vanilla CSS at what we have now will get you a long way. However, we still show raw markdowns to users when they visit our articles. Additionally, we might want to show some nicely formatted code snippets in our articles. Let's fix this now. For this, we need something that can transform the markdown into HTML components, e.g 1## Some sub header into 1<h2>Some sub header</h2> Luckily, there already is a create library for this: Goldmark. So, let's refactor the posts/posts.go file to parse the content we store using embed. 1//go:embed *.md 2var assets embed.FS // unexport assets 3 4type Manager struct { 5 posts embed.FS 6 markdownParser goldmark.Markdown 7} 8 9func NewManager() Manager { 10 md := goldmark.New( 11 goldmark.WithParserOptions( 12 parser.WithAutoHeadingID(), 13 parser.WithAttribute(), 14 ), 15 goldmark.WithRendererOptions( 16 html.WithHardWraps(), 17 html.WithXHTML(), 18 html.WithUnsafe(), 19 ), 20 ) 21 22 return Manager{ 23 posts: assets, 24 markdownHandler: md, 25 } 26} 27 28func (m *Manager) Parse(name string) (string, error) { 29 source, err := m.posts.ReadFile(name) 30 if err != nil { 31 return "", err 32 } 33 34 // Parse Markdown content 35 var htmlOutput bytes.Buffer 36 if err := m.markdownHandler.Convert(source, &htmlOutput); err != nil { 37 return "", err 38 } 39 40 return htmlOutput.String(), nil 41} Lastly, update the ArticleHandler to use the Manager: 1func ArticleHandler( 2 ctx echo.Context, 3 storage ArticleStorage, 4 postManager posts.Manager 5) error { 6 postSlug := ctx.Param("postSlug") 7 8 postModel, err := storage.GetPostBySlug(postSlug) 9 if err != nil { 10 return err 11 } 12 13 postContent, err := postManager.Parse(postModel.Filename) 14 if err != nil { 15 return err 16 } 17 18 19 return views.ArticlePage( 20 views.ArticlePagedata{ 21 Title: postModel.Title, 22 Content: postContent, 23 }, 24 ).Render( 25 ctx.Request().Context(), 26 ctx.Response().Writer, 27 ) 28} Try to edit your article by adding some code blocks, and they will now get nicely formatted. You can add custom themes to the parser so your code snippets will be shown with your favorite theme. After getting fed up with React, SPAs, and Javascript around 2021, I decided to re-write my personal webpage in Rust and wrote an article on how you could build a simple blog purely using Rust. It ended up becoming one of my most popular articles, and for good reason: Rust is exciting, fun to write, and blazingly fast. After a while, though, I started to feel frustrated with the development process for adding new features to my site: the feedback loop was simply too long. I've always been interested in solo entrepreneurship and technology. But, as I'm getting older, I realize that I might have been more interested in trying out new technologies. Great for learning and growing as an engineer, bad for shipping projects, and starting to see that sweet MRR grow. I decided to re-re-write my site once again, this time in Go, for multiple reasons: I write Go for a living. Simple language, with a decent type system and fast performance Blazingly fast compile times I write Go for a living. Simple language, with a decent type system and fast performance Blazingly fast compile times In this post, I will show how you can create your own personal blog using Go. I'll assume you're familiar with Go and know how to configure a router/database/server, and so on. Should you not, feel free to grab a clone of my Go starter template, Grafto , which has lots of things configured for you out of the box. Grafto Foundations I write all my stuff in markdown; if you're a developer who also wants to start blogging, chances are you also are quite familiar with it. After Go 1.16, where we got the embed package included in the standard library, most of our work was already done. We basically only need to have a way of storing some filenames, associating them with an ID or a slug, grabbing the file, and serving it to the user. Pretty simple. embed Whether you've created your own setup or grabbed a copy of Grafto, create a new directory in the root of your project called posts and in there, create a file called posts.go . Open it and add the following: posts posts.go 1package posts 2 3// imports omitted 4 5//go:embed *.md 6var Assets embed.FS 1package posts 2 3// imports omitted 4 5//go:embed *.md 6var Assets embed.FS Now, any file with the .md extension will get included in the binary that ultimately gets built once we run go run build . We can simply grab the files from our global Assets variable using Assets.ReadFile(name-of-file) to handle any error that might occur or return the file as a string, e.g.: .md go run build Assets Assets.ReadFile(name-of-file) 1file, err := Assets.ReadFile("my-post.md") 2if err != nil { 3 return err 4} 5 6return string(file) 1file, err := Assets.ReadFile("my-post.md") 2if err != nil { 3 return err 4} 5 6return string(file) It won't be pretty (we'll fix that later), but it gets the basic idea out, which we can build on. We have a way to include our blog posts in the binary; let's add a way to associate them with a slug. You could use an ID here, but it just looks better to have URLs like: "https://acme.com/my-first-blog-post" compared to "http://acme.com/44a2530b-567d-472f-9495-e2ee64e7ae6d". So, assuming you have a database up and running, add this table: 1create table posts ( 2 id uuid primary key, 3 created_at timestamp not null, 4 updated_at timestamp not null, 5 title varchar(255) not null, 6 filename varchar(255) not null, 7 slug varchar(255) not null 8); 1create table posts ( 2 id uuid primary key, 3 created_at timestamp not null, 4 updated_at timestamp not null, 5 title varchar(255) not null, 6 filename varchar(255) not null, 7 slug varchar(255) not null 8); Not much exciting going on here. Basically, for the unaware readers, the slug column above will be the title but URL-friendly. So if you have a post with the title "My First Blog Post," the slug equivalent would be "my-first-blog-post" easy. Lastly, we need to be able to serve this to readers. That flow would typically involve them hitting a landing page showing a list of articles they can choose from, which links to the article. The implementation of this will depend a bit on your setup, but let us implement the handler to deal with grabbing a specific article using Echo as our router. Assuming you have a route like /posts/:slug , create the following: /posts/:slug 1type ArticleStorage interface { 2 GetPostBySlug(slug string) (Post, error) 3} 4 5func ArticleHandler(ctx echo.Context, storage ArticleStorage) error { 6 postSlug := ctx.Param("postSlug") 7 8 postModel, err := storage.GetPostBySlug(postSlug) 9 if err != nil { 10 return err 11 } 12 13 postContent, err := posts.Assets.ReadFile(postModel.Filename) 14 if err != nil { 15 return err 16 } 17 18 return ctx.String(http.StatusOK, string(postContent)) 19} 1type ArticleStorage interface { 2 GetPostBySlug(slug string) (Post, error) 3} 4 5func ArticleHandler(ctx echo.Context, storage ArticleStorage) error { 6 postSlug := ctx.Param("postSlug") 7 8 postModel, err := storage.GetPostBySlug(postSlug) 9 if err != nil { 10 return err 11 } 12 13 postContent, err := posts.Assets.ReadFile(postModel.Filename) 14 if err != nil { 15 return err 16 } 17 18 return ctx.String(http.StatusOK, string(postContent)) 19} I'm putting some decisions on your part here in terms of implementing ArticleStorage. We just need something that grabs the data from the DB on the post based on the slug. something This is the foundation of what we need...but it's not pretty. Let's fix that by letting the server do what it was always supposed to do: return HTML. Enter templ If you've spent time in the Go ecosystem, chances are you've probably heard about templ . It lets you write HTML templates as Go packages, and it's such a pleasant way of building out UI. Add some HTMX and alpine.js, and you'll get at least 95% of what you get with SPAs, with the added complexity. templ It's good practice to have a base template that wraps around your other templates so we have a single point for adding things like stylesheets, javascript, metadata, etc. Create a directory in root called views and add the following to a file called base.templ . base.templ 1package views 2 3templ base() { 4 <!DOCTYPE html> 5 <html lang="en"> 6 <nav> 7 <a href="/">MBV Labs</a> 8 </nav> 9 <body> 10 { children... } 11 <footer> 12 <aside> 13 <p>Copyright ©2024 </p> 14 <p>All right reserved by MBV Labs</p> 15 </aside> 16 </footer> 17 </body> 18 </html> 19} 1package views 2 3templ base() { 4 <!DOCTYPE html> 5 <html lang="en"> 6 <nav> 7 <a href="/">MBV Labs</a> 8 </nav> 9 <body> 10 { children... } 11 <footer> 12 <aside> 13 <p>Copyright ©2024 </p> 14 <p>All right reserved by MBV Labs</p> 15 </aside> 16 </footer> 17 </body> 18 </html> 19} For this to work, you'll need to install templ and run templ generate which will produce a file called base_templ.go that we can then import into other templates to wrap around them. For the sake of brevity, we'll only create the template to show the actual article. Create a file called article.templ , and add the following: templ generate base_templ.go article.templ 1type ArticlePageData struct { 2 Title string 3 Content string 4} 5 6templ ArticlePage(data ArticlePageData) { 7 @layouts.Base() { 8 <div> 9 <div> 10 <h1>{ data.Title }</h1> 11 </div> 12 <article> 13 @unsafe(data.Content) 14 </article> 15 </div> 16 } 17} 1type ArticlePageData struct { 2 Title string 3 Content string 4} 5 6templ ArticlePage(data ArticlePageData) { 7 @layouts.Base() { 8 <div> 9 <div> 10 <h1>{ data.Title }</h1> 11 </div> 12 <article> 13 @unsafe(data.Content) 14 </article> 15 </div> 16 } 17} and run templ generate once again. templ generate We can now go back and update our ArticleHandler handler: 1func ArticleHandler(ctx echo.Context, storage ArticleStorage) error { 2 postSlug := ctx.Param("postSlug") 3 4 postModel, err := storage.GetPostBySlug(postSlug) 5 if err != nil { 6 return err 7 } 8 9 postContent, err := posts.Assets.ReadFile(postModel.Filename) 10 if err != nil { 11 return err 12 } 13 14 return views.ArticlePage( 15 views.ArticlePagedata{ 16 Title: postModel.Title, 17 Content: postContent, 18 }, 19 ).Render( 20 ctx.Request().Context(), 21 ctx.Response().Writer, 22 ) 23} 1func ArticleHandler(ctx echo.Context, storage ArticleStorage) error { 2 postSlug := ctx.Param("postSlug") 3 4 postModel, err := storage.GetPostBySlug(postSlug) 5 if err != nil { 6 return err 7 } 8 9 postContent, err := posts.Assets.ReadFile(postModel.Filename) 10 if err != nil { 11 return err 12 } 13 14 return views.ArticlePage( 15 views.ArticlePagedata{ 16 Title: postModel.Title, 17 Content: postContent, 18 }, 19 ).Render( 20 ctx.Request().Context(), 21 ctx.Response().Writer, 22 ) 23} If you run your application now and visit a valid URL, you should see a (rather ugly) page showing the markdown of your article, but this time, it should have some sweet hypertext markup. Making things (slightly) less ugly In terms of styling things, throwing some tailwind or vanilla CSS at what we have now will get you a long way. However, we still show raw markdowns to users when they visit our articles. Additionally, we might want to show some nicely formatted code snippets in our articles. Let's fix this now. For this, we need something that can transform the markdown into HTML components, e.g 1## Some sub header 1## Some sub header into 1<h2>Some sub header</h2> 1<h2>Some sub header</h2> Luckily, there already is a create library for this: Goldmark. So, let's refactor the posts/posts.go file to parse the content we store using embed. posts/posts.go 1//go:embed *.md 2var assets embed.FS // unexport assets 3 4type Manager struct { 5 posts embed.FS 6 markdownParser goldmark.Markdown 7} 8 9func NewManager() Manager { 10 md := goldmark.New( 11 goldmark.WithParserOptions( 12 parser.WithAutoHeadingID(), 13 parser.WithAttribute(), 14 ), 15 goldmark.WithRendererOptions( 16 html.WithHardWraps(), 17 html.WithXHTML(), 18 html.WithUnsafe(), 19 ), 20 ) 21 22 return Manager{ 23 posts: assets, 24 markdownHandler: md, 25 } 26} 27 28func (m *Manager) Parse(name string) (string, error) { 29 source, err := m.posts.ReadFile(name) 30 if err != nil { 31 return "", err 32 } 33 34 // Parse Markdown content 35 var htmlOutput bytes.Buffer 36 if err := m.markdownHandler.Convert(source, &htmlOutput); err != nil { 37 return "", err 38 } 39 40 return htmlOutput.String(), nil 41} 1//go:embed *.md 2var assets embed.FS // unexport assets 3 4type Manager struct { 5 posts embed.FS 6 markdownParser goldmark.Markdown 7} 8 9func NewManager() Manager { 10 md := goldmark.New( 11 goldmark.WithParserOptions( 12 parser.WithAutoHeadingID(), 13 parser.WithAttribute(), 14 ), 15 goldmark.WithRendererOptions( 16 html.WithHardWraps(), 17 html.WithXHTML(), 18 html.WithUnsafe(), 19 ), 20 ) 21 22 return Manager{ 23 posts: assets, 24 markdownHandler: md, 25 } 26} 27 28func (m *Manager) Parse(name string) (string, error) { 29 source, err := m.posts.ReadFile(name) 30 if err != nil { 31 return "", err 32 } 33 34 // Parse Markdown content 35 var htmlOutput bytes.Buffer 36 if err := m.markdownHandler.Convert(source, &htmlOutput); err != nil { 37 return "", err 38 } 39 40 return htmlOutput.String(), nil 41} Lastly, update the ArticleHandler to use the Manager: 1func ArticleHandler( 2 ctx echo.Context, 3 storage ArticleStorage, 4 postManager posts.Manager 5) error { 6 postSlug := ctx.Param("postSlug") 7 8 postModel, err := storage.GetPostBySlug(postSlug) 9 if err != nil { 10 return err 11 } 12 13 postContent, err := postManager.Parse(postModel.Filename) 14 if err != nil { 15 return err 16 } 17 18 19 return views.ArticlePage( 20 views.ArticlePagedata{ 21 Title: postModel.Title, 22 Content: postContent, 23 }, 24 ).Render( 25 ctx.Request().Context(), 26 ctx.Response().Writer, 27 ) 28} 1func ArticleHandler( 2 ctx echo.Context, 3 storage ArticleStorage, 4 postManager posts.Manager 5) error { 6 postSlug := ctx.Param("postSlug") 7 8 postModel, err := storage.GetPostBySlug(postSlug) 9 if err != nil { 10 return err 11 } 12 13 postContent, err := postManager.Parse(postModel.Filename) 14 if err != nil { 15 return err 16 } 17 18 19 return views.ArticlePage( 20 views.ArticlePagedata{ 21 Title: postModel.Title, 22 Content: postContent, 23 }, 24 ).Render( 25 ctx.Request().Context(), 26 ctx.Response().Writer, 27 ) 28} Try to edit your article by adding some code blocks, and they will now get nicely formatted. You can add custom themes to the parser so your code snippets will be shown with your favorite theme.