This package provides a request-aware rendering layer for Go templates. It lets applications render full pages or targeted partials from the same registered template tree, with wrapper/content composition, connector headers, OOB output, caching, and typed template-friendly data flow.
- Partial Templates: Define and render partial templates with typed dot data and functions.
- Native Template Composition: Use
{{ template "row.gohtml" . }}withSetDotso reusable sections can render inside a page or as HTMX targets. - Wrapper Content: Use wrapper partials with content children while keeping page data explicit.
- Template Caching: Enable caching of parsed templates for improved performance.
- Out-of-Band Rendering: Support for rendering out-of-band (OOB) partials.
- File System Support: Use custom fs.FS implementations for template file access.
- Concurrent Rendering: Configure partial trees up front, then reuse them safely across concurrent requests.
To install the package, run:
go get github.com/donseba/go-partialTemplate-facing helpers and accessors are documented in TEMPLATE_FUNCTIONS.md.
The root package is intentionally small: rendering lifecycle, partial trees, runtime context, connectors, Root partial configuration, and core template helpers. Core does not import optional packages.
Optional packages are split by stability:
ext/...contains extension packages that are useful but not required by core, such asext/errorsandext/debug.exp/...contains experimental opt-in features, such as localization, CSRF, flash messages, selection, actions, pageflow, interactions, metrics, OpenTelemetry, slots, target resolvers, template helpers, and SSE.
Applications choose the pieces they want with SetFunc(...), Use(...), or package-specific setup helpers. A render stage follows the same lifecycle everywhere: Prepare can add request-scoped context, Render wraps or replaces template rendering, and Finalize observes or transforms the result.
Render stages may set generic response metadata through ctx.Response. partial.Write applies that status and those headers after rendering. Templates do not receive helpers for setting headers or status; templates produce HTML.
For partials, prefer the package-level rendering functions:
html, err := partial.Render(ctx, content)
html, err = partial.RenderWithRequest(ctx, r, content)
err = partial.Write(ctx, w, r, content)partial.Write owns HTTP response behavior: configured headers, connector response headers, render-stage response metadata, error fragments, and out-of-band regions. Partial itself does not render or write responses; pass it to the package functions.
exp/metrics records render lifecycle data through a small Sink interface. Use your own sink for storage, or write JSON lines to any io.Writer:
sink := metrics.Fanout(
inMemoryStore,
metrics.NewWriterSink(os.Stdout),
)
root.Use(metrics.Stage(sink, metrics.WithTag("chain", "web")))The writer sink is intentionally plain: it works with stdout, files, buffers, pipes, or app-owned adapters that forward records to SSE, queues, or databases.
Metrics records distinguish request identity from flow identity. WithRequestID labels the current HTTP request. WithTraceID can group related browser work such as a page request and later async or SSE requests. WithParentRequestID can link a spawned request back to the request that caused it.
Core and extensions can emit diagnostic events without owning logging, tracing, or queues. Attach shared consumers to the reusable root partial:
events := partial.NewAsyncEvents(
partial.EventsConfig{Buffer: 256, DropPolicy: partial.DropNewest},
logger.Sink(slog.Default(), logger.WithMinLevel(partial.EventWarn)),
mySink,
)
root := partial.New("templates/shell.gohtml").
SetEvents(events)ext/logger adapts events to log/slog. App-owned sinks can forward the same events to DynamoDB, Kafka, OpenTelemetry, SSE debug pages, files, or any other collector. See OBSERVABILITY.md for concrete sink examples.
For request-owned collectors, attach a sink with partial.WithEventSink(r.Context(), sink) and close request-owned async dispatchers in middleware.
A documentation-style site built with go-partial is available in examples/docs.
go run ./examples/docsOpen http://localhost:8091.
An htmx-backed feature showcase with real template files is available in examples/showcase.
go run ./examples/showcaseOpen http://localhost:8090 to see pages for typed rows, selection partials, actions, flash messages, out-of-band rendering, context helpers, localization, HTMX response headers, server-sent events, live metrics, infinite scroll with cursor-style X-Action values, and the optional error page.
Several integrations are available, detailed information can be found in the INTEGRATIONS.md file.
- htmx
- Turbo
- Unpoly
- Partial, for framework-neutral fetch clients and tests
- SSE writer, for streaming rendered HTML patches
partial.Write can render an HTML error page when template parsing or execution fails, but only when an error stage is registered. Register ext/errors to choose the failure markup. In detailed mode, the page includes the partial ID, template list, request URL, template location, and original error so development and failed htmx requests still return useful output.
Normal requests receive a 500 full HTML page. HTMX/partial requests receive a swappable error fragment with status 200, because HTMX does not swap 500 responses by default.
Register the error stage on the reusable root partial:
root := partial.New("templates/shell.gohtml").
Use(exterrors.Stage(exterrors.WithMode(exterrors.ModeDetailed)))Or on one partial:
content.Use(exterrors.Stage())partial.RenderWithRequest still returns the render error directly. partial.Write asks the render stage chain for a failure response; without ext/errors, it returns the original render error.
Templates receive a request localizer through the localizer and locale helpers from exp/localization. The interface only requires GetLocale(). Translation behavior should come from user-provided template functions registered with Partial.SetFunc:
root.SetFunc(localization.FuncMap(), translator.FuncMap())
root.Use(localization.Stage())ctx := localization.WithLocalizer(r.Context(), localizer)
_ = partial.Write(ctx, w, r, content)<p>{{ tl localizer "Hello, World!" }}</p>
<p>{{ tn localizer "You have one message." "You have %d messages." 5 5 }}</p>
<button>{{ ctl localizer "button" "save" }}</button>tl, tn, ctl, and ctn are not built into go-partial. For a fuller translation backend, github.com/donseba/go-translator fits this pattern well because it exposes a compatible FuncMap().
The configured connector turns partial response instructions into protocol-specific response headers:
content := partial.NewID("notice", "notice.gohtml")
content.Response().
Retarget("#notice").
ReswapWith(connector.NewSwap().Style(connector.SwapOuterHTML).Transition(true)).
TriggerWith(connector.NewTrigger().AddEventObject("notice", map[string]any{
"message": "Saved",
}))You can also set the response instructions as data:
content.SetResponse(connector.Response{
Retarget: "#notice",
Trigger: connector.NewTrigger().AddEventObject("notice", map[string]any{
"message": "Saved",
}).String(),
})With the HTMX connector, these become HX-Retarget, HX-Reswap, and HX-Trigger headers during partial.Write.
The debug template helper renders a styled diagnostic box using an embedded template:
{{ debug runtime . }}
Register the debug helper and stage globally, per wrapper, or per partial:
root.SetFunc(debug.FuncMap())
root.Use(debug.Stage())SSE is a writer layer, not a connector. Use it after deciding which partials changed:
events := sse.NewWriter(w)
notice := partial.NewID("notice", "notice.gohtml").
SetDot(Notice{Message: "Saved"})
_ = events.PatchPartial(r.Context(), r, "#notice", notice)
_ = events.Signal("saved", true)
events.Flush()The writer declares constants for expected headers and event names, such as HeaderContentType, ContentTypeEventStream, EventPatch, EventSignal, and EventError.
Here's a simple typed example. The template owns the contract, the handler supplies the model.
The root partial holds shared render configuration and is cloned per request.
root := partial.NewID("shell", "templates/shell.html").
SetConnector(connector.NewHTMX(nil)).
SetFileSystem(os.DirFS("web")).
UseTemplateCache(true).
SetEvents(events).
SetFunc(template.FuncMap{
"money": formatMoney,
})A wrapper partial can render one configured content child through {{ content }}.
Create the tree inside the handler when the content, wrapper, dot data, or
registered children are request-specific. Clone the configured root blueprint
per request; per-request wiring should stay on the clone.
Create Partial instances for the content and any other components.
type ContentPage struct {
PageTitle string
Message string
}
type ShellPage struct {
AppName string
}
func handler(w http.ResponseWriter, r *http.Request) {
// Create the main content partial
content := partial.NewID("content", "templates/content.html").
SetDot(ContentPage{
PageTitle: "Home Page",
Message: "Welcome to our website!",
})
// Clone the shared shell and attach request-specific content.
page := root.Clone().
SetDot(ShellPage{AppName: "My Application"})
page.SetContent(content)
output, err := partial.RenderWithRequest(r.Context(), r, page)
if err != nil {
http.Error(w, "An error occurred while rendering the page.", http.StatusInternalServerError)
return
}
w.Write([]byte(output))
}templates/shell.html
<!DOCTYPE html>
<html>
<head>
<title>{{.AppName}}</title>
</head>
<body>
{{ content }}
</body>
</html>templates/content.html
<h1>{{ .PageTitle }}</h1>
<p>{{ .Message }}</p>Note: When a wrapper partial wraps content, it renders the configured route partial by calling {{ content }}.
Use SetDot for application data:
{{ .PageTitle }}
{{ .Message }}
For shared application values, put them on a typed model and declare them with go-doc @model:
{{/*
@model App github.com/example/app.AppInfo
*/}}
{{ App.AppName }}
wrapper.SetModel(AppInfo)When SetDot is used, request-specific values are available through helpers instead of fields on dot:
{{ ctx.URL.Path }}
{{ ctx.URL.Path }}
{{ request.Method }}
{{ locale }}
{{ csrf.Key }}
{{ basePath }}
go-partial can register go-doc typed root declarations before parsing templates. The declaration owns the template name, while the controller supplies the matching Go value:
{{/*
@model Page github.com/example/app.DashboardPage
*/}}
<h1>{{ Page.Title }}</h1>
content := partial.NewID("content", "templates/dashboard.gohtml").
SetModel(page)SetModel appends values to the typed roots already inherited through the partial tree. Use it when a partial adds local models on top of parent models. SetContract(annotation, values...) does the same thing for custom annotation names.
When more than one root has the same Go type, bind by name:
{{/*
@interaction LikesPoll github.com/donseba/go-partial/exp/interactions.Interaction
@interaction LikeButton github.com/donseba/go-partial/exp/interactions.Interaction
*/}}
{{ poll runtime LikesPoll }}
{{ refresh runtime LikeButton }}
content.SetContract("interaction",
interactions.NewPoll("/posts/42/likes").As("LikesPoll").Every(5*time.Second),
interactions.NewRefresh("/posts/42/likes").As("LikeButton").Target("#likes"),
)Interaction roots are normal go-doc contracts registered with SetContract("interaction", values...). Use As(name) when the endpoint does not naturally produce the contract name; otherwise the last endpoint segment is capitalized, so /stats becomes Stats.
Contract names cannot collide with go-partial helpers such as partial, locale, ctx, or url.
go-partial keeps interaction template helpers in github.com/donseba/go-partial/exp/interactions.
They are opt-in:
import "github.com/donseba/go-partial/exp/interactions"
root.SetFunc(interactions.FuncMap())Those functions include go-doc signature metadata for the overloads that normal
Go function declarations cannot express. The repository .go-doc/config.json
wires them into editor tooling for this repo so helper calls such as
{{ async runtime "/stats" }} and {{ async runtime Stats }} can both be
completed and validated. The runtime argument is a per-render value injected
by go-partial; it exposes request context, the active connector, partial tree,
and diagnostic stages to helpers without package globals.
Use the inline endpoint form when the endpoint is naturally local to the template, especially inside loops:
{{ range .Rows }}
{{ async runtime "/async/row/:row" "row" .ID }}
{{ end }}
Use a named interaction when Go should own stable IDs, targets, intervals, events, placeholders, or reuse:
{{/*
@interaction LikesPoll github.com/donseba/go-partial/exp/interactions.Interaction
*/}}
{{ poll runtime LikesPoll }}
content.SetContract("interaction",
interactions.NewPoll("/posts/42/likes").As("LikesPoll").Every(5*time.Second),
)For repeated sections that should be understood by go-doc and also render as HTMX targets, prefer native templates plus SetDot. The parent owns the loop, and the nested template receives the row as normal dot data:
{{/*
@dot github.com/example/app.TablePage
*/}}
{{ range .Rows }}
{{ template "row.html" . }}
{{/* or: {{ template "/templates/row.html" . }} */}}
{{ end }}
<tr id="row-{{ .ID }}">
<td>{{ .Name }}</td>
</tr>Register the row partial on the parent so the parent parse can see the row template and so an HTMX request can still target a row by ID:
rowPartial := partial.NewID("row", "templates/row.html")
table.With(rowPartial)
target.WithResolver(table, func(ctx context.Context, r *http.Request, target string) (*partial.Partial, bool) {
row := findRowForTarget(target)
return partial.NewID(target, "templates/row.html").SetDot(row), true
})For wrapper partials, render the route content through content:
<main>{{ content }}</main>
Avoid using partial as a general row-composition helper in new code. Native template calls keep the template idiomatic and give go-doc the strongest type information. Use partial when you intentionally want to render a template path through go-partial's render path:
{{ partial runtime "templates/notice.gohtml" .Notice }}
Keep registered partials for HTMX targets, OOB output, selection/action rendering, and places where the browser can request a stable partial ID.
Out-of-Band partials allow you to update parts of the page without reloading:
type Footer struct {
Text string
}
// Create the OOB partial
footer := partial.New("templates/footer.html").ID("footer")
footer.SetDot(Footer{
Text: "This is the footer",
})
// Add the OOB partial
p.WithOOB(footer)In your templates, you can use the oobAttr function to conditionally render OOB attributes.
templates/footer.html
<div{{ oobAttr }} id="footer">{{ .Text }}</div>You can add custom functions to be used within your templates:
import "strings"
// Define custom functions
funcs := template.FuncMap{
"upper": strings.ToUpper,
}
// Merge the functions into this partial tree
p.SetFunc(funcs)SetFunc registers helpers in the current scope. Function names inherited from the Root partial or parent partial remain available, and protected go-partial helper names cannot be overwritten.
go-partial reserves only the helpers it injects for rendering and request state:
runtime, partial, content, ctx, request, url, locale, csrf,
OOB helpers, and connector helpers such as targetIs, selectionIs, and
actionIs. Generic helpers such as dict, string helpers, and date helpers are
ordinary template functions and may be replaced.
Optional helper providers live under exp/:
import (
"github.com/donseba/go-partial/exp/flash"
"github.com/donseba/go-partial/exp/interactions"
"github.com/donseba/go-partial/exp/templatehelpers"
)
root.SetFunc(
flash.FuncMap(),
interactions.FuncMap(),
templatehelpers.StringFuncMap(),
templatehelpers.CollectionFuncMap(),
)For go-doc, point provider discovery at the same packages:
{
"providers": [
"github.com/donseba/go-partial/exp/flash",
"github.com/donseba/go-partial/exp/interactions",
"github.com/donseba/go-partial/exp/templatehelpers"
]
}{{ upper .Message }}If your templates are stored in a custom file system, set it with SetFileSystem:
import (
"embed"
)
//go:embed templates/*
var templatesFS embed.FS
p.SetFileSystem(templatesFS)If you do not use a custom file system, the package will use the default file system and look for templates relative to the current working directory.
For tables and repeated fragments, prefer native Go templates plus SetDot. The parent receives a typed page model, ranges over rows, and calls the row template with {{ template "row.html" . }}. That keeps the template readable for go-doc while go-partial still knows the row partial ID for HTMX target requests.
Example: Rendering a Table with Dynamic Rows
templates/table.html
<table>
{{ range .Rows }}
{{ template "row.html" . }}
{{ end }}
</table>templates/row.html
<tr id="row-{{ .ID }}">
<td>{{ .Name }}</td>
</tr>Go Code:
type Row struct {
ID int
Name string
}
type TablePage struct {
Rows []Row
}
rowPartial := partial.NewID("row", "templates/row.html")
tablePartial := partial.NewID("table", "templates/table.html").
SetDot(TablePage{Rows: rows})
tablePartial.With(rowPartial)
target.WithResolver(tablePartial, func(ctx context.Context, r *http.Request, target string) (*partial.Partial, bool) {
row, ok := findRowForTarget(target)
if !ok {
return nil, false
}
return partial.NewID(target, "templates/row.html").SetDot(row), true
})
out, err := partial.RenderWithRequest(r.Context(), r, tablePartial)If the child does not need custom configuration, use WithTemplate as shorthand:
tablePartial := partial.NewID("table", "templates/table.html").
WithTemplate("templates/row.html").
SetDot(TablePage{Rows: rows})This is equivalent to tablePartial.With(partial.NewID("row", "templates/row.html")).
In your templates, prefer this model:
- {{.}}: Your app model when the partial uses
SetDot. - Typed roots: Additional typed values registered with
SetModelorSetContract. - {{ctx}}, {{request}}, {{url}}, {{locale}}, {{csrf}}, {{basePath}}: request-aware helpers that stay available when
SetDotchanges..
go-partial does not wrap your model in .Data, .App, .Shell, or .Global. Shared application values should be explicit typed roots, for example SetModel(AppInfo) with a matching go-doc declaration. Request-scoped values live behind helper functions so changing dot never hides them.
Configure reusable root partials, functions, render stages, headers, and filesystems before serving requests. Clone before adding request-specific content or dot data. After configuration, partial.RenderWithRequest and partial.Write can be called concurrently on cloned partial trees. Request-specific values such as request, url, ctx, runtime, stage values, selected targets, and template helper bindings are scoped to the active render and are not stored on the reusable partial configuration.
In HTTP handlers, pass the request context, normally r.Context() or a context derived from it. That lets cancellation, deadlines, localization, CSRF state, event sinks, metrics IDs, and other middleware values follow the render. partial.Render(ctx, p) without a request is for tests, offline rendering, and small utilities.
Do not call configuration methods such as SetFunc, Use, With, SetDot, SetFileSystem, SetConnector, or SetResponseHeaders while the same tree is being rendered. Build or clone a separate tree when configuration needs to change at runtime.
Mutable builder and stream/session helpers are intentionally single-owner values. Do not share connector.ResponseBuilder, connector.Trigger, connector.Swap, sse.Writer, or pageflow.SessionData across goroutines without your own synchronization.
The package also includes concurrency safety measures for template caching:
- Parsed templates are cached by the configured partial tree.
- Mutexes prevent duplicate parsing for the same Root partial/template/function shape.
- Cached templates are rebound with request-specific functions per render.
- Rendered HTML is not cached.
- Set
UseTemplateCachetotrueto enable parsed template caching.
root.UseTemplateCache(true)You can render specific partials based on the X-Target header (or your custom header).
Example:
func handler(w http.ResponseWriter, r *http.Request) {
page := root.Clone().SetContent(content)
output, err := partial.RenderWithRequest(r.Context(), r, page)
if err != nil {
http.Error(w, "An error occurred while rendering the page.", http.StatusInternalServerError)
return
}
w.Write([]byte(output))
}To request a specific partial:
curl -H "X-Target: sidebar" http://localhost:8080with caching enabled
goos: darwin
goarch: arm64
pkg: github.com/donseba/go-partial
cpu: Apple M2 Pro
BenchmarkRenderWithRequest
BenchmarkRenderWithRequest-12 526102 2254 ns/op
PASSwith caching disabled
goos: darwin
goarch: arm64
pkg: github.com/donseba/go-partial
cpu: Apple M2 Pro
BenchmarkRenderWithRequest
BenchmarkRenderWithRequest-12 57529 19891 ns/op
PASSwhich would mean that caching is rougly 9-10 times faster than without caching
Contributions are welcome! Please open an issue or submit a pull request with your improvements.
This project is licensed under the MIT License.
