Web development articles for project-based learning

This is part of a series of articles. Read the other parts here:

  1. Build a REST API from scratch with Go, Docker & Postgres
  2. Build a fullstack app with Go Fiber, Docker, and Postgres

Introduction

In the first part of this tutorial series, we built a REST API with Go Fiber, Docker, and Postgres. We added endpoints that would allow us to create facts and list all our facts. In this second part, we’re going to convert the API into a fullstack Go Fiber app by adding some frontend views.

Prerequisites

To follow along, you will need to have Docker installed and running.

If you did not follow the first tutorial, but would like to follow along with this instalment, you can use the finished code from the first part as your starting point. You can find that in the repo for the first tutorial.

Preparing the app for templates

Out of the box, Go Fiber already has mechanisms that will allow us to add some frontend views to our app. We will need to use a template engine. Go Fiber supports several template engines. The full list of supported template engines can be found on their website. For this tutorial, we’re going to use the html template engine.

To install the necessary package, we can enter our web service container. Again, we’re assuming you have the code from the first part of this tutorial series.

1
docker compose run --service-ports web bash

Then, within the container, we can use the go get command to install the Go Fiber html template engine

1
go get github.com/gofiber/template/html

Next we will need to configure our Go Fiber app to handle templates correctly. We can head into the cmd/main.go file to do the necessary set up. First, we will import the html template package we just installed, and then we will use html.New() to initialise a new engine instance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import (
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/template/html" // 1. import
	"github.com/divrhino/divrhino-trivia/database"
)

func main() {
	database.ConnectDb()

	engine := html.New("./views", ".html") // 2. new engine

	app := fiber.New()

	setUpRoutes(app)

	app.Listen(":3000")
}

We will also have to update the app initialisation code and pass in some configuration options. In the options, we can set the Views property to the engine instance we created above.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/template/html"
	"github.com/divrhino/divrhino-trivia/database"
)

func main() {
	database.ConnectDb()

	engine := html.New("./views", ".html")

	app := fiber.New(fiber.Config{
		Views: engine, // new config
	})

	setUpRoutes(app)

	app.Listen(":3000")
}

Now that our app is configured for templates, we can add our first frontend view.

Index view

The first view we will create will be the index view. This is where we will display the list of all our Facts.

To organise our html template files, we will need to first create a views directory

1
mkdir views

Then inside this directory, we can create a new index.html file

1
touch views/index.html

Our index view is currently empty, but we will come back to it shortly.

Now we will head into our handlers/facts.go file because we will need to update our ListFacts handler so it returns an html template. Go Fiber gives us a Render() method on the fiber.Ctx, which will allow us to render a view.

We can pass Go data to this Render() method and it returns a text/html response. For now, will pass through some string data for a Title and Subtitle field.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package handlers

import (
	"github.com/divrhino/divrhino-trivia/database"
	"github.com/divrhino/divrhino-trivia/models"
	"github.com/gofiber/fiber/v2"
)

func ListFacts(c *fiber.Ctx) error {
	facts := []models.Fact{}
	database.DB.Db.Find(&facts)

	return c.Render("index", fiber.Map{
		"Title": "Div Rhino Trivia Time",
		"Subtitle": "Facts for funtimes with friends!",
	})
}

func CreateFact(c *fiber.Ctx) error {
	fact := new(models.Fact)
	if err := c.BodyParser(fact); err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": err.Error(),
		})
	}

	database.DB.Db.Create(&fact)

	return c.Status(200).JSON(fact)
}

These fields can be displayed in our html file using the Go template syntax i.e. double curly braces ({{ }}).

Now we can return to our views/index.html file and add the following content. Here we are using dot notation to display the Title and Subtitle fields of the current page.

1
2
<p>{{ .Title }}</p>
<p>{{ .Subtitle }}</p>

We can already test this out in the browser. We can exit from the web service container and run our app from the host terminal using the command:

1
docker compose up

Our .Title and .Subtitle should be present on the page at http://localhost:3000/

.Title and .Subtitle for index.html
Image: .Title and .Subtitle for index.html

Global layout

HTML pages share a lot of common elements like the <head> tag and navigation menus. Instead of rendering these elements on every page, individually, we can create a global layout. We can then use the global layout as the base for all our pages. In this section, we will update our app config settings to accomodate this.

Heading back to cmd/main.go, we need to revisit our app’s config object. We will add a ViewsLayout property and set it to "layouts/main". This will apply the main layout to all our pages, by default.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
	"github.com/divrhino/divrhino-trivia/database"
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/template/html"
)

func main() {
	database.ConnectDb()

	engine := html.New("./views", ".html")

	app := fiber.New(fiber.Config{
		Views: engine,
		ViewsLayout: "layouts/main", // add this to config
	})

	setUpRoutes(app)

	app.Listen(":3000")
}

This main layout file does not exist yet. So when we try to test this in the browser, we get this error:

Error message in browser
Image: Error message in browser

We can resolve the error by creating the main layout. Let’s first create a new folder for our layouts.

1
mkdir views/layouts

Then we can create the main.html layout file here

1
touch views/layouts/main.html

Opening up views/layouts/main.html, we’ll add the following content.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!DOCTYPE html>
<head>
    <title>Div Rhino Trivia</title>
</head>
<body>
    <div class="wrapper">
        <h1>{{.Title}}</h1>
        <h2>
            {{.Subtitle}}
        </h2>
        {{embed}}
    </div>
</body>
</html>

It’s just a basic HTML layout with a couple of headings. We’re rendering our .Title data within <h1> tags and our .Subtitle data within <h2> tags. Below these, we have an {{embed}}. The {{embed}} is where our individual page content will be rendered.

We have arbitrarily decided that all of our pages will have a {{.Title}} and {{.Subtitle}} field, so we can render them in the main layout, so that all our pages get the same layout. Of course, the contents of the fields will be different on each page because each page has its own context.

Remember that we already have a {{.Title}} and {{.Subtitle}} rendered in our index.html template. So if we check our browser now, we see double

Double title and subtitle
Image: Double title and subtitle

To fix this, we need to remove the fields from our index.html file and replace them with some temporary text:

1
2
3
<!-- index.html -->

This content is unique to the index page

If we head into our browser now, we should see our main layout content has rendered along with the unique text we put in our index.html file.

Main layout with unique text from index page
Image: Main layout with unique text from index page

Displaying all facts on the index page

In the previous step, we rendered a couple of string values in our template. It was a good demonstration of how static content can be displayed in a template. In the following section, we will build on this knowledge to be able to render dynamic content.

Let’s update the ListFacts handler so that it sends facts from the database to our frontend template. As mentioned previously, we can send any type of Go data to our templates. The facts variable holds a slice of Fact, the underlying type of a Fact is struct.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package handlers

import (
	"github.com/divrhino/divrhino-trivia/database"
	"github.com/divrhino/divrhino-trivia/models"
	"github.com/gofiber/fiber/v2"
)

func ListFacts(c *fiber.Ctx) error {
	facts := []models.Fact{}
	database.DB.Db.Find(&facts)

	return c.Render("index", fiber.Map{
		"Title": "Div Rhino Trivia Time",
		"Subtitle": "Facts for funtimes with friends!",
		"Facts":    facts, // send the facts to the view
	})
}

func CreateFact(c *fiber.Ctx) error {
	fact := new(models.Fact)
	if err := c.BodyParser(fact); err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": err.Error(),
		})
	}

	database.DB.Db.Create(&fact)

	return c.Status(200).JSON(fact)
}

Inside the index.html view, we can range over the Facts and display each of them as follows.

1
2
3
4
5
6
7
8
<div>
	{{ range .Facts }}
	<div>
	    <p>{{ .Question }}</p>
	    <p>{{ .Answer }}</p>
	</div>
	{{ end }}
</div>

We should also handle scenarios where no Facts are present. We can use if / else blocks to do some conditional rendering. If there are Facts present, we will range over and render each one. If there are no Facts, we will display a message that says there are no facts with a link to /fact where we can create new facts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<div>
    {{ if .Facts }}
        {{ range .Facts }}
        <div>
            <p>{{ .Question }}</p>
            <p>{{ .Answer }}</p>
        </div>
        {{ end }}
    {{ else }}
        <div>
            <p>No facts yet.</p>
            <p><a href="/fact">Add new</a></p>
        </div>
    {{ end }}
</div>

We’ve cleared the database, so there are currently no Facts present. Our index view currently looks like this in the browser:

No facts present yet
Image: No facts present yet

The Add new link goes to a page that does not exist yet, so we will create it in the next section.

Create new fact view

The next view we will create will be the new.html view. This template will contain a form that can be used to submit new Facts.

In the views folder, we will create a new template file called new.html:

1
touch views/new.html

In the first part of this tutorial series, we added a route to POST new facts to the API. We now need to add a route that will GET the “new fact” template.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
	"github.com/divrhino/divrhino-trivia/handlers"
	"github.com/gofiber/fiber/v2"
)

func setupRoutes(app *fiber.App) {
	app.Get("/", handlers.ListFacts)

	app.Get("/fact", handlers.NewFactView) // Add new route for new view
	app.Post("/fact", handlers.CreateFact)
}

The handlers.NewFactView function doesn’t exist yet, so let’s head into the handlers/facts.go file to create it. For now, we will just add a Title and a Subtitle, we will build on it later.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package handlers

import (
	"github.com/divrhino/divrhino-trivia/database"
	"github.com/divrhino/divrhino-trivia/models"
	"github.com/gofiber/fiber/v2"
)

func ListFacts(c *fiber.Ctx) error {
	facts := []models.Fact{}
	database.DB.Db.Find(&facts)

	return c.Render("index", fiber.Map{
		"Title": "Div Rhino Trivia Time",
		"Subtitle": "Facts for funtimes with friends!",
		"Facts":    facts,
	})
}

// Create new Fact View handler
func NewFactView(c *fiber.Ctx) error {
	return c.Render("new", fiber.Map{
		"Title":    "New Fact",
		"Subtitle": "Add a cool fact!",
	})
}

func CreateFact(c *fiber.Ctx) error {
	fact := new(models.Fact)
	if err := c.BodyParser(fact); err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": err.Error(),
		})
	}

	database.DB.Db.Create(&fact)

	return c.Status(200).JSON(fact)
}

We can test out this new template by visiting http://localhost:3000/fact in the browser.

New fact view
Image: New fact view

At the moment, we only have the default content from the main layout rendering on the page because our new.html file is empty. We will work on creating our form in the next step.

Use a form to create new facts

Back in our new.html view, we will write some markup for our new form:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<form id="new-form" method="POST" action="/fact">
    <label for="question">
        <span>Question</span>
        <input id="question" type="text" name="question">
    </label>

    <label for="answer">
        <span>Answer</span>
        <input id="answer" type="text" name="answer">
    </label>

    <input type="submit" value="Submit">
</form>

Without adding anything more, we can already submit this form data. However, when we do submit a new fact, the UX of it is a little awkward, as it redirects to the JSON response (in Firefox, anyway). We can fix that by redirecting to a success or confirmation page.

Submitted fact redirects to JSON in browser
Image: Submitted fact redirects to JSON in browser

Success page

Our success or confirmation view will show us a message to confirm that our form data has been submitted successfully.

Let’s create a new template called confirmation.html:

1
touch views/confirmation.html

This confirmation.html file will contain a link that takes users back to the fact creation page

1
2
3
<div class="add-more">
    <p><a href="/fact">Add more</a></p>
</div>

Then we can head into the handlers/facts.go file to add a handler for our confirmation view. We won’t add a route for this handler because we only need to use it within our CreateFact() handler.

We will also update the CreateFact() handler to return our new ConfirmationView:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package handlers

import (
	"github.com/divrhino/divrhino-trivia/database"
	"github.com/divrhino/divrhino-trivia/models"
	"github.com/gofiber/fiber/v2"
)

func ListFacts(c *fiber.Ctx) error {
	facts := []models.Fact{}
	database.DB.Db.Find(&facts)

	return c.Render("index", fiber.Map{
		"Title": "Div Rhino Trivia Time",
		"Subtitle": "Facts for funtimes with friends!",
		"Facts":    facts,
	})
}

func NewFactView(c *fiber.Ctx) error {
	return c.Render("new", fiber.Map{
		"Title":    "New Fact",
		"Subtitle": "Add a cool fact!",
	})
}

func CreateFact(c *fiber.Ctx) error {
	fact := new(models.Fact)
	if err := c.BodyParser(fact); err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
			"message": err.Error(),
		})
	}

	database.DB.Db.Create(&fact)

	return ConfirmationView(c) // 2. Return confirmation view
}

// 1. New Confirmation view
func ConfirmationView(c *fiber.Ctx) error {
	return c.Render("confirmation", fiber.Map{
		"Title":    "Fact added successfully",
		"Subtitle": "Add more wonderful facts to the list!",
	})
}

We can head into the browser to test out the confirmation page. Once we’ve submitted a form, we will be redirected to our confirmation page rather than the JSON response

Confirmation page
Image: Confirmation page

Partials

There are several ways we can make our markup more reusable. Previously, we saw how to create a layout to share common structural page elements between our views. Another technique would be to make use of partials.

We need a header, but we don’t want to copy/paste the header markup for every one of our views. So let’s put the header in a partial. We can start by creating a views/partials folder

1
mkdir views/partials

Then add a new header.html file to it:

1
touch views/partials/header.html

Inside header.html, we can define our partial

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{{define "header"}}
<header>
    <div>
        <a href="/">
            <img src="image/divrhino-logo.png">
        </a>
    </div>
    <div class="">
        <a href="/fact">
            <span>+ New Fact</span>
        </a>
    </div>
</header>
{{end}}

We can now use our header partial in views/layouts/main.html. The . means we’re passing through the current context

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{{define "main"}}
<!DOCTYPE html>
<head>
    <title>Div Rhino Trivia</title>
</head>
<body>
    <div class="wrapper">
        {{template "header" .}} <!-- import partial -->

        <h1>{{.Title}}</h1>
        <h2>
            {{.Subtitle}}
        </h2>
        {{embed}}
    </div>
</body>
</html>

If we look at our header partial, we will notice that we are importing a logo image that doesn’t exist yet. And while the image is not present, our app doesn’t know how to handle images yet, either. In the next section, we’ll configure our app to handle static assets.

Missing logo image in header
Image: Missing logo image in header

Serving static assets

Although our app is functional, it isn’t much to look at. We can improve that with some static assets such as images, CSS or a bit of JavaScript.

Go Fiber has methods to help us achieve this fairly easily. Let’s head back into our main.go file and use the app.Static method to tell our app where to locate our static assets. In our case, we will be putting our images, CSS and JS in a folder called public. We have chosen to call it public because its somewhat of a convention, but you can call this folder anything you like.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
	"github.com/gofiber/fiber/v2"
	"github.com/gofiber/template/html"
	"github.com/divrhino/divrhino-trivia/database"
)

func main() {
	database.ConnectDb()

	engine := html.New("./views", ".html")

	app := fiber.New(fiber.Config{
		Views:       engine,
		ViewsLayout: "layouts/main",
	})

	setUpRoutes(app)

	app.Static("/", "./public")

	app.Listen(":3000")
}

The public folder does not exist yet, so let’s create it in the root of our project

1
mkdir public

Now we can move on and adding our logo image.

Images

To keep our images organised, we will create a folder in our public directory called image:

1
mkdir public/image

We can put our logo image file into this folder.

Our logo image is now present in the header on our index page. If we visit other pages, the header partial is also present.

Header with logo
Image: Header with logo

Linking stylesheets

Apart from images, we can also add some CSS stylesheets.

To keep our stylesheets organised, let’s create a folder in our public directory called style:

1
mkdir public/style

Then we can create a stylesheet within it:

1
touch public/style/main.css

We don’t have anything in our main.css file yet, but let’s link it in our head tag first and add some styles later. In the main layout, we can add this link tag to the <head>. We’ve added a query param of v=1 after the href value as a rudimentary way to bust the cache while we’re developing, locally. I’m sure there are other ways to do this.

1
<link rel="stylesheet" href="style/main.css?v=1">

Let’s also add the viewport meta tag to the <head> so our app can be responsive and display correctly on mobile devices:

1
<meta name="viewport" content="width=device-width,initial-scale=1">

The index.html template should look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<head>
    <title>Div Rhino Trivia</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="stylesheet" href="style/main.css?=1">
</head>
<body>
    <div class="wrapper">
        {{template "header" .}}

        <h1>{{.Title}}</h1>
        <h2>
            {{.Subtitle}}
        </h2>
        {{embed}}
    </div>
</body>
</html>

Inside our main.css file, we can change the body element’s background colour

1
2
3
body {
    background-color: aquamarine;
}

We will have to update our markup and paste in some pre-prepared styles. We will not go over the specifics here, as this is not a CSS tutorial. But the completed markup and stylesheet can be found in the Github repo.

JavaScript

We’ve added a bit more markup to make the layout a little more interesting. We’ve added a little toggle button next to each of our facts.

Toggle button not interactive
Image: Toggle button not interactive

Right now, they are not functional. Let’s add some javascript to make these buttons interactive.

To keep our JavaScript organised, let’s create a folder in our public directory called javascript:

1
mkdir public/javascript

Then we can create a JavaScript file within it:

1
touch public/javascript/app.js

We don’t have anything in our app.js file yet, but let’s link it in our head tag first and add our JS later. In the index.html view, we can import this <script> tag at the bottom of the file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="container">
    {{if .Facts}}
        {{ range .Facts }}
            <div class="fact-item">
                <div class="question-wrapper">
                    <p class="question">{{ .Question }}</p>
                    <button id="answer-toggle" class="answer-toggle">Toggle answer</button>
                </div>
                <div class="answer-wrapper">
                    <p class="answer">{{.Answer}}</p>
                </div>
            </div>
        {{ end }}
    {{else}}
        <div class="no-facts">
            <p>No facts yet.</p>
            <p><a href="/fact">Add new</a></p>
        </div>
    {{end}}
</div>

<script src="/javascript/app.js"></script>

We will also paste in some pre-prepared JS. Again, the final javascript file can be found in the Github repo.

Toggle button animated
Image: Toggle button animated

And with that, we’ve come to the end.

Conclusion

In this tutorial we learnt how to add a form and other frontend views to a Go Fiber app that was build from scratch using Go and Docker. We started with a simple API and extended it to include templates and static assets.

Congratulations, you did great! Keep learning and keep coding. Bye for now, <3.