Skip to main content

Views & Templates

Arrower renders HTML using Go's html/template. No template engine, just the standard library plus Sprig functions. Templates are loaded from an fs.FS and support hot reload during development.

Template Hierarchy

Templates are composed in layers. Each layer defines blocks that the next layer can override:

┌──────────────────────────────────────────┐
│ Base Layout (default.base.html) │ HTML <head>, <body>, htmx, global CSS/JS
│ ┌────────────────────────────────────────│
│ │ Context Layout (default.layout.html) │ Sidebar, nav, context chrome
│ │ ┌──────────────────────────────────────│
│ │ │ Page (pages/*.html) │ Page content + page-level CSS/JS
│ │ │ ┌────────────────────────────────────┤
│ │ │ │ Fragment (page.html#fragment) │ HTMX partial, rendered standalone
│ │ │ ├────────────────────────────────────┤
│ │ │ │ Component (components/*.html) │ Reusable, embedded in pages
│ │ │ └────────────────────────────────────│
│ │ └──────────────────────────────────────│
│ └────────────────────────────────────────│
└──────────────────────────────────────────┘

Base Layout

Root HTML skeleton (shared/views/default.base.html). Defines the outer document structure: <!doctype html>, <head>, <body>, htmx, global scripts and styles. Provides empty blocks for layers to fill:

{{ block "layout.css" . }}{{ end }}
{{ block "page.css" . }}{{ end }}
{{ block "layout.js" . }}{{ end }}
{{ block "page.js" . }}{{ end }}

{{ block "layout" . }}
<main>
{{ block "content" . }}
Fallback content
{{ end }}
</main>
{{ end }}

Context Layout

Context-specific chrome like sidebars and navigation (default.layout.html in each context's views). Overrides the layout block and provides a new content block for pages.

Page

The actual page content placed in the content block. Pages can also override page.css, page.js, and title blocks.

Fragment (HTMX)

Render only part of a page for HTMX partial updates. Uses the # separator. Skips all layouts, renders just the named fragment.

Rendering

// Full page: base layout + context layout + page
c.Render(http.StatusOK, "jobs.index", echo.Map{"jobs": jobs})

// Fragment: skip layouts, render just the #fragment from the page (for [HTMX](https://htmx.org) partial updates)
c.Render(http.StatusOK, "jobs.maintenance#table-size", data)

Injecting Page-Level CSS and JS

The base layout defines empty blocks in <head>. Pages and context layouts override them to inject page-specific assets:

<!-- pages/jobs.index.html -->
{{ define "page.js" }}
<script src="static/admin/js/chart.js"></script>
<script src="static/admin/js/behaviors/pending-jobs.js"></script>
{{ end }}

{{ define "admin.title" }}Job Queues{{ end }}

<!-- page content starts here -->
<div id="queues">
...
</div>

The same pattern works at the context layout level with layout.css and layout.js blocks.

Directory Structure

views/
├── default.base.html # Base layout (HTML skeleton)
├── default.layout.html # Context layout (optional)
├── pages/
│ ├── home.html # Full page
│ ├── jobs.index.html # Page with fragments
│ └── login.html
└── components/
└── header.html # Reusable component
  • Files in the root are layouts (parsed as *.base.html or *.layout.html)
  • pages/ contains page templates
  • components/ contains shared reusable fragments

Presenter Pattern

Keep views dumb. When a page needs data transformation: formatting dates, computing display values, pretty-printing JSON -> use a presenter instead of putting logic in templates.

Presenters are optional. Use them when the page needs more than a simple map or struct. For simple pages, pass echo.Map or a struct directly.

views/pages/jobs.queue.go
// BuildQueuePage transforms domain data into view-ready data.
func BuildQueuePage(queue string, jobs []jobs.Job, kpis jobs.QueueKPIs) QueuePage {
return QueuePage{
QueueName: queue,
Stats: queueKpiToStats(queue, kpis),
Jobs: prettyFormatPayload(jobs),
}
}

type QueuePage struct {
Jobs []viewJob
QueueName string
Stats QueueStats
}

type viewJob struct {
RunAtFmt string // pre-formatted for display
jobs.Job
}

Then in the controller:

func (ctrl *JobsController) ShowQueue() func(c echo.Context) error {
return func(c echo.Context) error {
// ...fetch domain data...

return c.Render(http.StatusOK, "jobs.queue",
pages.BuildQueuePage(queue, domainJobs, kpis),
)
}
}

Reusable Components

Place shared template fragments in components/*.html. They are loaded automatically and available in all pages.

Components are not rendered standalone - unlike fragments, they have no layout and cannot be called directly from a controller. They are building blocks embedded in pages using Go's standard template call:

<!-- components/header.html -->
<header>
<h1>{{ .Title }}</h1>
</header>
<!-- inside any page -->
{{ template "header" . }}

Base Data

Provide data available in all pages across all contexts:

renderer.AddBaseData("default", func(ctx context.Context) (map[string]any, error) {
return map[string]any{"appName": "myapp"}, nil
})

Context Views

Each context registers its own view fs.FS and can provide layout data that is merged into every page in that context:

renderer.AddContext("auth", authViews)

renderer.AddLayoutData("auth", "default", func(ctx context.Context) (map[string]any, error) {
return map[string]any{"user": currentUser}, nil
})

Layout data functions run on every render for that context, so all pages get the data automatically.

Built-in Template Functions

FunctionDescription
All Sprig functionsString manipulation, date formatting, math, etc.
routeReverse route resolution: {{ route "auth.login" }}
ti18n translation placeholder
Custom functionsPass any template.FuncMap to the renderer constructor

Hot Reload

In development, the Arrower middleware watches template files for changes and reloads the page in the browser automatically. Edit a template, save - the browser updates without manual refresh.