# A Simple Web App for Image Generation with Dall-E 3 using Go + HTMX

- **URL:** https://isaacfei.com/posts/gen-img-go-htmx
- **Date:** 2025-03-23
- **Tags:** Go, HTMX, Dall-E
- **Description:** Build a single-page web app with Go and HTMX to generate multiple images concurrently with Dall-E 3.

---

## What Will We Build? And My Motivation

In this post, we'll build a single-page web application that generates images using OpenAI's DALL-E 3 API. The app features:

- A simple and yet beautiful UI built with Tailwind CSS and DaisyUI
- HTMX for frontend interactions without JavaScript
- Capability of sending multiple requests to the Dall-E 3 API concurrently

App screenshot:
![app-screenshot](./app-screenshot.png)

I've been itching to play around with Go and HTMX 🤩, so I figured, why not build a simple web app with them? Plus, I've been needing to generate AI images every now and then—like for this blog post's cover image. Instead of generating one image at a time and picking through them manually, I thought it'd be way more convenient to build a little app that spits out a bunch of options at once and lets me easily pick the best one.

This is not a step-by-step tutorial. Instead, I will focus on introducing the main ideas and highlighting the key parts of the code. For the complete source code, please refer to my repository [gen-img](https://github.com/Isaac-Fate/gen-img).

See section [How to Run](#how-to-run) for the instructions to run the app.

## Introduction to HTMX and Templ

HTMX is a modern library that simplifies building dynamic, interactive web applications using HTML attributes. It enables features like AJAX requests, CSS transitions, and server-side rendering without requiring complex JavaScript. By extending HTML with attributes such as `hx-get`, `hx-post`, `hx-target`, and `hx-swap`, HTMX allows developers to create seamless user experiences with minimal effort.

When paired with Go and the Go Templ package, HTMX becomes a powerful tool for building server-rendered web applications. The Go Templ package is a templating engine that lets you dynamically generate HTML on the server side. HTMX complements this by handling client-side interactions, enabling smooth DOM updates without full page reloads.

To build UIs with Templ, you write `.templ` files, which mix HTML and Go code. These files are compiled into normal Go code using the `templ generate` command (or automatically via live-reloading tools like Air). This approach feels similar to writing TSX in React or using the `view!` macro in Rust's Leptos framework.

Together, HTMX and Templ leverage the strengths of both technologies: Go's performance and simplicity for server-side logic, and HTMX's lightweight approach to enhancing HTML for dynamic behavior. This combination provides a streamlined workflow for building modern, efficient, and maintainable web applications.

## Project Structure

```
gen-img/
├── cmd/
│   └── web/
│       └── main.go           // Application entry point
├── internal/
│   └── templs/
│       ├── components/       // Reusable UI components
│       ├── layouts/          // Page layouts
│       └── pages/            // Pages
├── pkg/
│   └── genimg/               // Image generation package
│       ├── genimg.go         // Core image generation logic
│       └── genimg_test.go    // Tests
├── .air.toml                 // Air configuration
├── .env.example              // Environment variables template
├── go.mod                    // Go module file
└── go.sum                    // Go dependencies checksum
```

## Setup

### Gin

We use Gin as the backend framework.

Add it to your project:

```bash
go get github.com/gin-gonic/gin
```

### Templ

Even though this app is simple enough,
you don't want to write every component in a single HTML file.

Templ is a powerful templating engine for Go that allows us to scaffold the app in a modular way.
We'll use it to build our UI components and pages.

Add the `templ` package:

```bash
go get github.com/a-h/templ/cmd/templ@latest
```

If you are developing in VSCode, you may isntall the extension [templ-vscode](https://marketplace.visualstudio.com/items?itemName=a-h.templ) to get syntax highlighting and autocompletion for `.templ` files.

### Air

[Air](https://github.com/air-verse/air) is a live reload tool for Go applications. It watches your files for changes and automatically rebuilds and restarts your application.

Install Air:

```bash
go install github.com/cosmtrek/air@latest
```

Once installed, you can run the air command in your project directory. By default, Air will look for a .air.toml configuration file in the current directory for any custom settings.

To get started, create a .air.toml file in your project's root directory. Then, as recommended in the documentation, copy the default full configuration into it.

we will modify the following sections with the explanations below:

- `build.pre_cmd`: Prevent generating file `pre_cmd.txt`
- `build.cmd`: Customize the command to build the main file located at `./cmd/web/main.go`
- `build.post_cmd`: Prevent generating file `post_cmd.txt`
- `build.include_ext`: Watch the `.templ` files

```toml {10, 12, 14, 22}
# .air.toml

# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "."
tmp_dir = "tmp"

[build]
# Array of commands to run before each build
pre_cmd = ["echo 'this is pre cmd'"]
# Just plain old shell command. You could use `make` as well.
cmd = "templ generate && go build -o ./tmp/main ./cmd/web/main.go"
# Array of commands to run after ^C
post_cmd = ["echo 'this is post cmd'"]
# Binary file yields from `cmd`.
bin = "tmp/main"
# Customize binary, can setup environment variables when run your app.
full_bin = "APP_ENV=dev APP_USER=air ./tmp/main"
# Add additional arguments when running binary (bin/full_bin). Will run './tmp/main hello world'.
args_bin = ["hello", "world"]
# Watch these filename extensions.
include_ext = ["go", "tpl", "tmpl", "html", "templ"]
# ...
```

### HTMX

To use HTMX, simply include the CDN link in the HTML head:

```templ {14-15}
// internal/templs/layouts/base_layout.templ

package layouts

templ BaseLayout(title string) {
    <!DOCTYPE html>
    <html lang="en" data-theme="dark">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">

            <title>{ title }</title>

            // HTMX
            <script src="https://unpkg.com/htmx.org"></script>
        </head>

        <body class="h-screen overflow-clip">
            { children... }
        </body>
    </html>
}
```

### Tailwind CSS v4 + DaisyUI

I usually use Tailwind CSS (now on v4) and [Shadcn UI](https://ui.shadcn.com/) for my frontend projects.
But since this project doesn't use a framework like React or Vue, Shadcn is not applicable.

Instead, I'm going with [DaisyUI](https://daisyui.com/). It is a nice UI library with ready-to-use components and utilities. The best part? You can just include it via CDN—no setup needed.

Add these to the base layout file:

```diff lang=templ
// internal/templs/layouts/base_layout.templ
package layouts

templ BaseLayout(title string) {
    <!DOCTYPE html>
    <html lang="en" data-theme="dark">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">

            <title>{ title }</title>

            // HTMX
            <script src="https://unpkg.com/htmx.org"></script>

            // Tailwind CSS v4
            <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

+            // Daisy UI
+            <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
            
+            // Daisy UI Themes
+            <link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
        </head>

        <body class="h-screen overflow-clip">
            { children... }
        </body>
    </html>
}
```

## Backend: Send Multiple Requests to OpenAI Concurrently

### Send a Single Request

The core functionality starts with the `GenerateImage` function that sends a single request to the DALL-E 3 API:

```go
// pkg/genimg/genimg.go

func GenerateImage(endpoint string, apiKey string, prompt string, imageSize string) (string, error) {

	// Create the payload
	payload := map[string]any{
		"model":           "dall-e-3",
		"prompt":          prompt,
		"size":            imageSize,
		"response_format": "url",
	}

	// Convert to JSON
	payloadJson, err := json.Marshal(payload)
	if err != nil {
		return "", err
	}

	// Create the request
	req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(payloadJson))
	if err != nil {
		return "", err
	}

	// Set the headers
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

	// Send the request
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}

	// Read the response body

	body, err := io.ReadAll(resp.Body)
	defer resp.Body.Close()

	if err != nil {
		return "", err
	}

	// Decode the JSON response into a struct
	var responseData imageGenerationResponseData
	err = json.Unmarshal(body, &responseData)
	if err != nil {
		return "", err
	}

	// Get the image URL

	if len(responseData.Data) == 0 {
		return "", errors.New("data is empty")
	}

	imageUrl := responseData.Data[0].Url

	return imageUrl, nil
}
```

### Send Multiple Requests

You may notice from OpenAI's API documentation that there is a `n` parameter that allows us to generate multiple images concurrently.
So, why not just send a single request with the `n` parameter set to the number of images we want to generate?
I tried, but it told me that the model doesn't support it yet 😂.

To generate multiple images efficiently, we use a worker pool pattern with goroutines. This approach allows us to handle concurrent requests to the API without overwhelming it, while still maintaining good performance.
Let's see the code:

```go
// pkg/genimg/genimg.go

func GenerateImages(ctx context.Context, endpoint string, apiKey string, prompt string, imageSize string, numImages int, maxWorkers int) []string {

	// A buffered channel of all image URLs to return
	imageUrlChan := make(chan string, numImages)

	// A buffered channel of all requests to send
	jobChan := make(chan struct{}, numImages)

	// Create a wait goup
	var wg sync.WaitGroup

	for range maxWorkers {

		// Increment the wait group
		wg.Add(1)

		// Start a worker
		go worker(ctx, &wg, endpoint, apiKey, prompt, imageSize, jobChan, imageUrlChan)

	}

	// Send the jobs
	for range numImages {
		jobChan <- struct{}{}
	}

	// Done sending jobs
	close(jobChan)

	go func() {
		// Wait for the wait group to finish
		wg.Wait()

		// Close the channel
		close(imageUrlChan)
	}()

	// Slice of all URLs of generated images
	var imageUrls []string
	for imageUrl := range imageUrlChan {
		imageUrls = append(imageUrls, imageUrl)
	}

	return imageUrls
}

func worker(ctx context.Context, wg *sync.WaitGroup, endpoint string, apiKey string, prompt string, imageSize string, jobChan <-chan struct{}, imageUrlChan chan<- string) {

	// Decrement the wait group counter
	defer wg.Done()

	for range jobChan {
		select {

		case <-ctx.Done():
			return

		default:
			// Generate a single image
			imageUrl, err := GenerateImage(endpoint, apiKey, prompt, imageSize)

			// Collect the image URL
			if err == nil {
				imageUrlChan <- imageUrl
			}

		}
	}

}
```

Key points of this implementation:

- Buffered Channels:
  - `jobChan` is used to distribute jobs (tasks) to workers.
  - `imageUrlChan` collects the results (generated image URLs) from workers.
- Worker Pool:
  - A configurable number of worker goroutines `maxWorkers` are spawned to handle jobs concurrently.
- Context Handling:
  - The `ctx.Done()` check ensures that workers can gracefully exit if the context is canceled (e.g., due to a timeout).
- Asynchronous Result Collection:
  - Results are collected in a separate goroutine, allowing the main function to return all generated image URLs once all workers are done.
- Wait Group:
  - The `sync.WaitGroup` ensures that the main function waits for all workers to finish before closing the `imageUrlChan`.

The worker pool pattern is ideal for this use case because:

- It limits the number of concurrent requests to the API, preventing overload.
- It maximizes efficiency by processing multiple requests in parallel.
- It ensures that all results are collected and returned in a structured way.

### Set Up the Gin Router

In this section, we configure the Gin router to handle two main routes: `/` for rendering the home page and `/generate` for processing form submissions to generate images.

```go
// cmd/web/main.go

package main

import (
	"context"
	"gen-img/internal/templs/components"
	"gen-img/internal/templs/pages"
	"gen-img/pkg/genimg"
	"os"
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
)

const numWorkers = 10

func main() {

	// Load the dotenv
	err := godotenv.Load()
	if err != nil {
		panic(err)
	}

	// Get the API key
	apiKey := os.Getenv("OPENAI_API_KEY")
	if apiKey == "" {
		panic("OPENAI_API_KEY is not set")
	}

	router := gin.Default()

	// Render the home page
	router.GET("/", func(ctx *gin.Context) {
		pages.Home().Render(ctx.Request.Context(), ctx.Writer)
	})

	router.POST("/generate", func(ctx *gin.Context) {

		// Get fields from the submitted form data

		endpoint := ctx.PostForm("endpoint")
		prompt := ctx.PostForm("prompt")
		imageSize := ctx.PostForm("imageSize")

		numImagesAsString := ctx.PostForm("numImages")
		numImages, err := strconv.Atoi(numImagesAsString)
		if err != nil {
			panic(err)
		}

		// Create a context with a timeout
		c, cancel := context.WithTimeout(context.Background(), 60*time.Second)
		defer cancel()

		// Send the request to generate the images
		images := genimg.GenerateImages(c, endpoint, apiKey, prompt, imageSize, numImages, numWorkers)

		// Render the image list
		// HTMX will plug it in under the image list container
		components.ImageList(images).Render(ctx.Request.Context(), ctx.Writer)
	})

	router.Run()
}
```

## Frontend: Build the UI in `.templ` Files

The UI is built using templ components. The main structure consists of:

1. A base layout (`internal/templs/layouts/base_layout.templ`)
2. A home page (the one and only page) (`internal/templs/pages/home.templ`)
3. Reusable components (`internal/templs/components/`)

This design may seem familiar to you if you have ever used a modern frontend framework.

### Base Layout

The base layout provides the common structure for all pages:

```templ
// internal/templs/layouts/base_layout.templ

package layouts

templ BaseLayout(title string) {
    <!DOCTYPE html>
    <html lang="en" data-theme="dark">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">

            <title>{ title }</title>

            // HTMX
            <script src="https://unpkg.com/htmx.org"></script>

            // Tailwind CSS v4
            <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

            // Daisy UI
            <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
            
            // Daisy UI Themes
            <link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
        </head>

        <body class="h-screen overflow-clip">
            { children... }
        </body>
    </html>
}

```

### Home Page

The home page includes:

- A header with the app title
- A request form component that triggers the POST request `/generate` to the backend
- A container for displaying generated images

```templ
// internal/templs/pages/home.templ

package pages

import "gen-img/internal/templs/layouts"
import "gen-img/internal/templs/components"


templ Home() {
    @layouts.BaseLayout("gen-img") {
        <main class="flex flex-col h-full gap-8 p-8">
            // Title 
            <header class="flex flex-col justify-center text-2xl font-bold">
                gen-img
            </header>

            <div class="flex flex-row h-full justify-between gap-8">
                
                <div class="flex flex-row gap-2">
                    // Request Form
                    @components.RequestForm()

                    // Divider
                    <div class="divider divider-horizontal">👉</div>
                </div>
                
                // Image List Container
                // Its child will be replaced by a image list after the request is finished
                <div id="image-list-container" class="overflow-y-auto h-full">
                </div>
            </div>
        </main>
    }
}
```

Note we assigned the ID `image-list-container` to the container element. In the request form component, the `hx-target` attribute is set to `#image-list-container`. This means that when the form is submitted, the response will automatically replace the content inside the `image-list-container` element. Thanks to HTMX, this results in a seamless update of the container without requiring a full page reload.

### Request Form Component

The request form component handles user input and HTMX interactions:

```templ
// internal/templs/components/request_form.templ

package components

import "gen-img/pkg/genimg"

templ RequestForm() {

    <fieldset class="fieldset w-xs bg-base-200 border border-base-300 p-4 rounded-box">
        <legend class="fieldset-legend">RequestForm</legend>

        <form
            hx-post="/generate"
            hx-target="#image-list-container"
            hx-swap="innerHTML" 
            hx-on="htmx:beforeRequest: this.setAttribute('data-loading', 'true')
                htmx:afterRequest: this.removeAttribute('data-loading')"
        class="flex flex-col gap-4 group"
    >
        // API Endpoint
        <label class="fieldset-label">Endpoint</label>
        <label class="input validator">
            <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g stroke-linejoin="round" stroke-linecap="round" stroke-width="2.5" fill="none" stroke="currentColor"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></g></svg>
            <input type="url" required placeholder="https://" name="endpoint" value={ genimg.OpenaiImagesApiEndpoint } pattern="^(https?://)?([a-zA-Z0-9]([a-zA-Z0-9\-].*[a-zA-Z0-9])?\.)+[a-zA-Z].*$" title="Must be valid URL"  />
        </label>


        // Prompt
        <label class="fieldset-label">Prompt</label>
        <textarea
            name="prompt" 
            placeholder="Enter your prompt..."
            class="textarea"
        />


        // Image Size
        // Either square 1024x1024 or landscape 1792x1024
        <label class="fieldset-label">Image Size</label>
        <div class=" flex flex-row gap-4">
            <div class="flex flex-row gap-2 items-center">
                <input type="radio" name="imageSize" class="radio-sm" value={ genimg.ImageSizeLandscape } checked />
                <label for="landscape">Landscape</label>
            </div>

            <div class="flex flex-row gap-2 items-center">
                <input type="radio" name="imageSize" class="radio-sm" value={ genimg.ImageSizeSquare } />
                <label for="square">Square</label>
            </div>
        </div>

        
        // Number of Images
        <label class="fieldset-label">Number of Images</label>
        <input 
            type="number"
            required placeholder="Type a number between 1 to 50" 
            name="numImages" 
            value="1"
            min="1" 
            max="50" 
            title="Must be between be 1 to 50" 
            class="input validator" 
        />
        
        // Submit Button
        <button 
            type="submit"
            class="btn btn-neutral"
        >
            <span class="animate-spin size-6 group-data-[loading=true]:flex hidden" aria-hidden="true">
                <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg>
            </span>
            Generate
        </button>
    </form>
    </fieldset>
}
```

### Image List Component

The image list component displays the generated images:

```templ
// internal/templs/components/image_list.templ

package components


templ ImageList(images []string) {
	<div class="image-list sm:grid flex flex-col md:grid-cols-3 sm:grid-cols-2 lg:grid-cols-4 gap-4">
		for _, image := range(images) {
			<img src={ image }/>
		}
	</div>
}
```

## How to Run

1. Clone the repository and go to the project directory:

```bash
git clone https://github.com/Isaac-Fate/gen-img.git
cd gen-img
```

2. Install the dependencies:

```bash
go mod download
```

3. Create a `.env` file and add your OpenAI API key:

```bash
cp .env.example .env
```

Fill in the `.env` file with your OpenAI API key.

4. Run the application:

```bash
go run cmd/web/main.go
```

Since this ptoject uses Gin framework, you may run it in the realse mode setting the `GIN_MODE` environment variable to `release`:

```bash
GIN_MODE=release go run cmd/web/main.go
```

Also, you may also change the default port (8080) by setting the `PORT` environment variable:

```bash
PORT=8081 go run cmd/web/main.go
```

5. Visit `http://localhost:8080` (or the port you set) in your browser.