How to build a fullstack application with Go, Templ, and HTMX
In this guide, you’ll learn how to build a fullstack application with Go using Templ, HTMX, and Xata.
Demola Malomo
Apr 23 2024
7 min read
Go is a statically typed, compiled high-level programming language for building systems, command-line interfaces (CLI), and more. It is typically designed for use on the backend; however, there are times when you want to use the same language to build a full-stack application with a functional backend and a visual frontend.
In most cases, Go developers opt for frontend frameworks/libraries like React, Vue, Angular, etc., to build the frontend part of the application. This means they must learn JavaScript/TypeScript, framework-specific paradigms, and other frontend-related overheads.
In this guide, you’ll learn how to build a fullstack application with Go using Templ, HTMX, and Xata.
Technology Overview
Templ: is a templating engine that lets you build HTML with Go. It also lets you use Go syntax like if
, switch
, and for
statements to build a robust frontend. You will use Templ to build reusable components and pages for the frontend.
HTMX: is a frontend library that lets you access modern browser features directly using HTML rather than JavaScript. You will use HTMX to process the form submission and perform other dynamic operations.
Xata: is a serverless database with analytics and free-text search support that makes a wide range of applications easy to build.
Prerequisites
To follow along with this tutorial, the following are needed:
- Go version 1.20 or higher installed
- Basic understanding of Go
- Xata account. Signup is free
Getting started
To get started, you need to install Templ binary. The binary generates Go code from a Templ file.
1go install github.com/a-h/templ/cmd/templ@latest
Create a directory.
1mkdir go_fullstack && cd go_fullstack
Next, initialize a Go module to manage project dependencies.
1go mod init go-fullstack
Finally, we proceed to install the required dependencies with:
1go get github.com/gin-gonic/gin github.com/a-h/templ github.com/joho/godotenv
github.com/gin-gonic/gin
is a framework for building web applications.
github.com/a-h/templ
is the Templ library used in the project.
github.com/joho/godotenv
is a library for loading environment variables.
Structuring the application
To do this, create a cmd
, internals
, and views
folder in our project directory.
cmd
is for structuring the application entry point.
internals
is for structuring API-related files.
views
is for structuring frontend-related files.
Setup the database on Xata
Log into the Xata workspace and create a todo
database. Inside the todo
database, create a Todo
table and add a description
column of type String
.
Get the Database URL and set up the API Key
To get the database URL, click the Get code snippet button and copy the URL. Then click the API Key link, add a new key, save and copy the API key.
Setup environment variable
Create a .env
file in the root directory and add the copied URL and API key.
1XATA_DATABASE_URL= <REPLACE WITH THE COPIED DATABASE URL> 2XATA_API_KEY=<REPLACE WITH THE COPIED API KEY>
Build the application Frontend
To build the frontend, you’ll use Templ and HTMX to structure the application and add dynamism to a Todo application.
Create the application components
Inside the views
folder, create a components/header.templ
file and add the snippet below:
1package components 2 3templ Header() { 4 <head> 5 <script 6 src="https://unpkg.com/htmx.org@1.9.10" 7 integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" 8 crossorigin="anonymous" 9 ></script> 10 <script src="https://cdn.tailwindcss.com"></script> 11 <meta charset="UTF-8" /> 12 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 13 <title>GO Fullstack</title> 14 </head> 15}
The snippet creates a Header
component and adds HTMX and TailwindCSS CDNs. TailwindCSS is a low-level framework for styling.
Next, create a components/footer.templ
file to create the application footer and style using TailwindCSS classes.
1package components 2 3templ Footer() { 4 <footer class="fixed p-1 bottom-0 bg-gray-100 w-full border-t"> 5 <div class="rounded-lg p-4 text-xs italic text-gray-700 text-center"> 6 © Go Fullstack 7 </div> 8 </footer> 9}
Finally, create an index.templ
file inside the same views
folder and add the snippet below:
1package views 2 3import ( 4 "fmt" 5 "go_fullstack/views/components" 6) 7 8type Todo struct { 9 Id string 10 Description string 11} 12 13templ Index(todos []*Todo) { 14 <!DOCTYPE html> 15 <html lang="en"> 16 @components.Header() 17 <body> 18 <main class="min-h-screen w-full"> 19 <nav class="flex w-full border border-b-zinc-200 px-4 py-4"> 20 <h3 class="text-base lg:text-lg font-medium text-center"> 21 GO Fullstack app 22 </h3> 23 </nav> 24 <div class="mt-6 w-full flex justify-center items-center flex-col"> 25 // FORM PROCESSING 26 <form 27 hx-post="/" 28 hx-trigger="submit" 29 hx-swap="none" 30 onsubmit="reloadPage()" 31 class="w-96" 32 > 33 <textarea 34 name="description" 35 cols="30" 36 rows="2" 37 class="w-full border rounded-lg mb-2 p-4" 38 placeholder="Input todo details" 39 required 40 ></textarea> 41 <button 42 class="py-1 px-4 w-full h-10 rounded-lg text-white bg-zinc-800" 43 > 44 Create 45 </button> 46 </form> 47 <section class="border-t border-t-zinc-200 mt-6 px-2 py-4 w-96"> 48 // LOOP THROUGH THE TODOS 49 <ul id="todo-list"> 50 for _, todo := range todos { 51 <li class="ml-4 ml-4 border p-2 rounded-lg mb-2" id={ fmt.Sprintf("%s", todo.Id) }> 52 <p class="font-medium text-sm">Todo item { todo.Id }</p> 53 <p class="text-sm text-zinc-500 mb-2"> 54 { todo.Description } 55 </p> 56 <div class="flex gap-4 items-center mt-2"> 57 <a 58 href="#" 59 class="flex items-center border py-1 px-2 rounded-lg" 60 > 61 <p class="text-sm">Edit</p> 62 </a> 63 <button 64 hx-delete={ fmt.Sprintf("/%s", todo.Id) } 65 hx-swap="delete" 66 hx-target={ fmt.Sprintf("#%s", todo.Id) } 67 class="flex items-center border py-1 px-2 rounded-lg hover:bg-red-300" 68 > 69 <p class="text-sm">Delete</p> 70 </button> 71 </div> 72 </li> 73 } 74 </ul> 75 </section> 76 </div> 77 </main> 78 </body> 79 @components.Footer() 80 </html> 81 <script> 82 function reloadPage() { 83 setTimeout(function() { 84 window.location.reload(); 85 }, 2000); 86 } 87 </script> 88}
The snippet above does the following:
- Imports the required dependencies
- Creates a
Todo
struct to represent the response data coming from the backend - Creates an
Index
component that uses theHeader
andFooter
components to structure the page. Then, it uses the HTMX attributes to process form submissions and deletion of todos by calling the respective endpoints/
and/{todo.Id}
Generating Go files from the Templ files
Next, use the Templ binary you installed earlier to Generate Go codes from the views created above by running the command below in your terminal:
1templ generate
After you run this command, you’ll see new Go files generated for each view. You use generated file to render your frontend in next section.
The generated files are not to be edited.
Putting it together and building the backend
With that done, you can use it to build the backend and render the required page.
Create the API models and helper function
To represent the application data, create a model.go
file inside the internals
folder and add the snippet below:
1package internals 2 3type Todo struct { 4 Id string `json:"id,omitempty"` 5 Description string `json:"description,omitempty"` 6} 7 8type TodoRequest struct { 9 Description string `json:"description,omitempty"` 10} 11 12type TodoResponse struct { 13 Id string `json:"id,omitempty"` 14}
The snippet above creates a Todo
, TodoRequest
, and TodoResponse
struct with the required properties to describe requests and response types.
Finally, create a helpers.go
file with a reusable function to load environment variables.
1package internals 2 3import ( 4 "log" 5 "os" 6 "github.com/joho/godotenv" 7) 8 9func GetEnvVariable(key string) string { 10 err := godotenv.Load() 11 if err != nil { 12 log.Fatal("Error loading .env file") 13 } 14 return os.Getenv(key) 15}
Create the application and API routes
Create a route.go
file for configuring the API routes and add the snippet below:
1package api 2 3import "github.com/gin-gonic/gin" 4 5type Config struct { 6 Router *gin.Engine 7} 8 9func (app *Config) Routes() { 10 //routes will come here 11}
The snippet above does the following:
- Imports the required dependency
- Creates a
Config
struct with aRouter
property to configure the application methods - Creates a
Routes
function that takes in theConfig
struct as a pointer
Create the API services
With that done, create a xata_service.go
file for the application and update it by doing the following:
First, import the required dependencies and create a helper function:
1package internals 2 3import ( 4 "bytes" 5 "encoding/json" 6 "fmt" 7 "net/http" 8) 9 10var xataAPIKey = GetEnvVariable("XATA_API_KEY") 11var baseURL = GetEnvVariable("XATA_DATABASE_URL") 12 13func createRequest(method, url string, bodyData *bytes.Buffer) (*http.Request, error) { 14 var req *http.Request 15 var err error' 16 17 if method == "GET" || method == "DELETE" || bodyData == nil { 18 req, err = http.NewRequest(method, url, nil) 19 } else { 20 req, err = http.NewRequest(method, url, bodyData) 21 } 22 23 if err != nil { 24 return nil, err 25 } 26 27 req.Header.Add("Content-Type", "application/json") 28 req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", xataAPIKey)) 29 30 return req, nil 31}
The snippet above does the following:
- Imports the required dependencies
- Creates required environment variables
- Creates a
createRequest
function that creates HTTP requests with the required headers
Lastly, add a createTodoService
, deleteTodoService
, and getAllTodosService
methods to create, delete, and get the list of todos.
1//imports goes here 2 3func createRequest(method, url string, bodyData *bytes.Buffer) (*http.Request, error) { 4 //createRequest code goes here 5} 6 7func (app *Config) createTodoService(newTodo *TodoRequest) (*TodoResponse, error) { 8 createTodo := TodoResponse{} 9 jsonData := Todo{ 10 Description: newTodo.Description, 11 } 12 13 bodyData := new(bytes.Buffer) 14 json.NewEncoder(bodyData).Encode(jsonData) 15 16 fullURL := fmt.Sprintf("%s:main/tables/Todo/data", baseURL) 17 req, err := createRequest("POST", fullURL, bodyData) 18 if err != nil { 19 return nil, err 20 } 21 22 client := &http.Client{} 23 resp, err := client.Do(req) 24 if err != nil { 25 return nil, err 26 } 27 28 defer resp.Body.Close() 29 if err := json.NewDecoder(resp.Body).Decode(&createTodo); err != nil { 30 return nil, err 31 } 32 33 return &createTodo, nil 34} 35func (app *Config) deleteTodoService(id string) (string, error) { 36 fullURL := fmt.Sprintf("%s:main/tables/Todo/data/%s", baseURL, id) 37 client := &http.Client{} 38 req, err := createRequest("DELETE", fullURL, nil) 39 if err != nil { 40 return "", err 41 } 42 43 resp, err := client.Do(req) 44 if err != nil { 45 return "", err 46 } 47 defer resp.Body.Close() 48 49 return id, nil 50} 51 52func (app *Config) getAllTodosService() ([]*Todo, error) { 53 var todos []*Todo 54 55 fullURL := fmt.Sprintf("%s:main/tables/Todo/query", baseURL) 56 client := &http.Client{} 57 req, err := createRequest("POST", fullURL, nil) 58 if err != nil { 59 return nil, err 60 } 61 62 resp, err := client.Do(req) 63 if err != nil { 64 return nil, err 65 } 66 67 defer resp.Body.Close() 68 var response struct { 69 Records []*Todo `json:"records"` 70 } 71 72 decoder := json.NewDecoder(resp.Body) 73 if err := decoder.Decode(&response); err != nil { 74 return nil, err 75 } 76 77 todos = response.Records 78 return todos, nil 79}
Create the API handlers
With that done, you can use the services to create the API handlers. Create a handler.go
file inside internals
folder and add the snippet below:
1package internals 2 3import ( 4 "context" 5 "fmt" 6 "go_fullstack/views" 7 "net/http" 8 "time" 9 "github.com/a-h/templ" 10 "github.com/gin-gonic/gin" 11) 12 13const appTimeout = time.Second * 10 14 15func render(ctx *gin.Context, status int, template templ.Component) error { 16 ctx.Status(status) 17 return template.Render(ctx.Request.Context(), ctx.Writer) 18} 19 20func (app *Config) indexPageHandler() gin.HandlerFunc { 21 return func(ctx *gin.Context) { 22 _, cancel := context.WithTimeout(context.Background(), appTimeout) 23 defer cancel() 24 25 todos, err := app.getAllTodosService() 26 if err != nil { 27 ctx.JSON(http.StatusBadRequest, err.Error()) 28 return 29 } 30 31 var viewsTodos []*views.Todo 32 for _, todo := range todos { 33 viewsTodo := &views.Todo{ 34 Id: todo.Id, 35 Description: todo.Description, 36 } 37 viewsTodos = append(viewsTodos, viewsTodo) 38 } 39 40 render(ctx, http.StatusOK, views.Index(viewsTodos)) 41 } 42} 43 44func (app *Config) createTodoHandler() gin.HandlerFunc { 45 return func(ctx *gin.Context) { 46 _, cancel := context.WithTimeout(context.Background(), appTimeout) 47 description := ctx.PostForm("description") 48 defer cancel() 49 50 newTodo := TodoRequest{ 51 Description: description, 52 } 53 54 data, err := app.createTodoService(&newTodo) 55 if err != nil { 56 ctx.JSON(http.StatusBadRequest, err.Error()) 57 return 58 } 59 60 ctx.JSON(http.StatusCreated, data) 61 } 62} 63 64func (app *Config) deleteTodoHandler() gin.HandlerFunc { 65 return func(ctx *gin.Context) { 66 _, cancel := context.WithTimeout(context.Background(), appTimeout) 67 id := ctx.Param("id") 68 defer cancel() 69 70 data, err := app.deleteTodoService(id) 71 if err != nil { 72 ctx.JSON(http.StatusBadRequest, err.Error()) 73 return 74 } 75 76 ctx.JSON(http.StatusAccepted, fmt.Sprintf("Todo with ID: %s deleted successfully!!", data)) 77 } 78}
The snippet above does the following
- Imports the required dependencies
- Creates a
render
function that uses theTempl
package to render matching template - Creates an
indexPageHandler
function that returns a Gin-gonic handler and takes in theConfig
struct as a pointer. Inside the returned handler, use thegetAllTodosService
service to get the list of todos and then render the appropriate page using the generated code from the views package (frontend) - Creates a
createdTodoHandler
anddeleteProjectHandler
functions that return a Gin-gonic handler and take in theConfig
struct as a pointer. Use the service created earlier to perform the corresponding action inside the returned handler
Update the API routes to use handlers
Update the routes.go
file with the handlers as shown below:
1package internals 2 3import ( 4 "github.com/gin-gonic/gin" 5) 6 7type Config struct { 8 Router *gin.Engine 9} 10 11func (app *Config) Routes() { 12 //views 13 app.Router.GET("/", app.indexPageHandler()) 14 15 //apis 16 app.Router.POST("/", app.createTodoHandler()) 17 app.Router.DELETE("/:id", app.deleteTodoHandler()) 18}
Putting it all together
Create the application entry point to use to serve the routes. To do this, create a main.go
file inside the cmd
folder and add the snippet below:
1package main 2 3import ( 4 "go_fullstack/internals" 5 "github.com/gin-gonic/gin" 6) 7 8func main() { 9 router := gin.Default() 10 11 //initialize config 12 app := internals.Config{Router: router} 13 14 //routes 15 app.Routes() 16 17 router.Run(":8080") 18}
The snippet above does the following:
- Imports the required dependencies
- Creates a Gin router using the
Default
configuration - Initialize the
Config
struct by passing in theRouter
- Adds the route and run the application on port
:8080
With that done, you can start a development server using the command below:
go run cmd/main.go
The complete source code can be found on GitHub.
Conclusion
This post discusses how to build a fullstack application with Go, Templ, HTMX, and Xata. You can extend the application further to support viewing and editing todos.
These resources may also be helpful: