A lightweight Go package for rendering content in multiple formats.
Zero allocations on the hot path — configuration is a plain value struct, not closures.
- Built-in renderers: JSON, XML, CSV, Text, HTML, Binary, YAML, Markdown, Gzip
- Composable renderers: Buffer, Cache, Multi, Pipe
- Template rendering via the
tmplsub-package (html/templateandtext/template) - Form rendering via the
formssub-package — themable, Symfony-inspired field/form rendering on top oftmpl - Context support — cancellation checked on every render call; timeout supported by composable renderers (Buffer, Cache, Pipe, Multi)
- Zero-allocation options —
Optionsis a plain struct passed by value - Thread-safe by design — renderers hold no mutable state after construction
go get github.com/nanoninja/render
All renderers implement a two-method interface:
type Renderer interface { ContentType() string Render(ctx context.Context, w io.Writer, data any, opts Options) error }
Options is a plain value struct — set only the fields you need:
type Options struct { Timeout time.Duration // zero means no limit Name string // template name (tmpl renderers) Pretty bool // enable indented output Indent string // indentation string (JSON, XML) Prefix string // per-line prefix (JSON, XML) LineEnding string // CSV line ending ("\n" or "\r\n") Args []any // printf-style args (Text renderer) Separator rune // CSV field separator (default ',') Padding string // JSONP callback name (JSON renderer) Headers map[string][]string } var NoOptions = Options{} // zero value, no allocation
// Prefer structs over maps — structs encode with 1 alloc, maps require // reflect.MapIter and allocate one value per key (9 allocs for 4 fields). type Response struct { Message string `json:"message"` } ctx := context.Background() data := Response{Message: "hello"} // Compact JSON render.JSON().Render(ctx, os.Stdout, data, render.NoOptions) // Pretty-printed JSON render.JSON().Render(ctx, os.Stdout, data, render.Options{Pretty: true})
// Default (HTML-safe, 2-space indent when pretty) render.JSON().Render(ctx, w, data, render.NoOptions) // Pretty with custom indent render.JSON().Render(ctx, w, data, render.Options{Pretty: true, Indent: " "}) // JSONP render.NewJSON(render.JSONConfig{Padding: "callback"}).Render(ctx, w, data, render.NoOptions) // Disable HTML escaping render.NewJSON(render.JSONConfig{EscapeHTML: false}).Render(ctx, w, data, render.NoOptions)
// Default (with XML header, 2-space indent when pretty) render.XML().Render(ctx, w, data, render.NoOptions) // Pretty with custom indent and prefix render.XML().Render(ctx, w, data, render.Options{Pretty: true, Indent: " ", Prefix: "//"}) // Without XML declaration header render.NewXML(render.XMLConfig{Header: false}).Render(ctx, w, data, render.NoOptions)
data := [][]string{ {"name", "age"}, {"Alice", "30"}, } // Default (comma-separated, LF line endings) render.CSV().Render(ctx, w, data, render.NoOptions) // Custom delimiter render.CSV().Render(ctx, w, data, render.Options{Separator: ';'}) // Windows line endings render.CSV().Render(ctx, w, data, render.Options{LineEnding: "\r\n"})
// Plain string render.Text().Render(ctx, w, "Hello, World!", render.NoOptions) // With printf-style arguments render.Text().Render(ctx, w, "Hello, %s!", render.Options{Args: []any{"Gopher"}}) // With trailing newline render.Text().Render(ctx, w, "Hello", render.Options{Pretty: true})
Accepts []byte or io.Reader.
render.Binary().Render(ctx, w, fileBytes, render.NoOptions) render.Binary().Render(ctx, w, file, render.NoOptions)
// Default (2-space indent) render.YAML().Render(ctx, w, data, render.NoOptions) // Custom indent render.NewYAML(render.YAMLConfig{Indent: 4}).Render(ctx, w, data, render.NoOptions)
Converts Markdown to HTML using goldmark.
// Standard CommonMark render.Markdown().Render(ctx, w, "# Hello", render.NoOptions) // GitHub Flavored Markdown (tables, strikethrough, task lists) render.NewMarkdown(render.MarkdownConfig{GFM: true}).Render(ctx, w, content, render.NoOptions) // Allow raw HTML (disabled by default for XSS safety) render.NewMarkdown(render.MarkdownConfig{Unsafe: true}).Render(ctx, w, content, render.NoOptions)
Compresses []byte input. Typically the last step in a Pipe.
render.Pipe( render.JSON(), render.Gzip(), ).Render(ctx, w, data, render.NoOptions)
Writes the same rendered output to multiple destinations simultaneously.
var log bytes.Buffer render.Multi(render.JSON(), &log).Render(ctx, w, data, render.NoOptions)
Chains renderers — the output of each becomes the input of the next.
render.Pipe( render.JSON(), render.Gzip(), ).Render(ctx, w, data, render.NoOptions)
Renders to an in-memory buffer first, then writes to the destination. Supports post-processing.
// Simple buffered render render.Buffer(render.JSON()).Render(ctx, w, data, render.NoOptions) // With post-processing render.NewBuffer(render.JSON(), render.BufferConfig{ PostRender: func(b []byte) ([]byte, error) { return bytes.ToUpper(b), nil }, }).Render(ctx, w, data, render.NoOptions)
Renders once, stores the output in memory, and replays it on subsequent calls.
Safe for concurrent use.
// Permanent cache — renders once, reused forever var countries = render.Cache(render.JSON()) // Cache with TTL — refreshed after expiry var catalog = render.NewCache(render.JSON(), render.CacheConfig{ TTL: 5 * time.Minute, })
Warning: the cache stores rendered bytes, not the data. Never cache user-specific or permission-restricted responses.
The tmpl sub-package wraps Go's standard template packages:
tmpl.HTML—html/templatewith automatic XSS escaping, for web pagestmpl.Text—text/template, for emails, CLI output, or plain text
import ( "github.com/nanoninja/render" "github.com/nanoninja/render/tmpl" "github.com/nanoninja/render/tmpl/loader" ) t := tmpl.HTML("myapp", tmpl.WithDefaultFuncsHTML()) if err := t.Load(src); err != nil { log.Fatal(err) } // Render a named template t.Render(ctx, w, data, render.Options{Name: "index.html"})
WithDefaultFuncsHTML / WithDefaultFuncs provide:
upper, lower, trim, replace, contains, hasPrefix, hasSuffix,
split, join, truncate, default, ternary, first, last, dict,
int, float, now, date, add, sub, mul, div, sum, avg, nl2br
The forms sub-package describes form fields in Go and renders them as HTML through a swappable theme — no HTML written by hand, no struct-tag reflection.
import ( "github.com/nanoninja/render" "github.com/nanoninja/render/forms" "github.com/nanoninja/render/tmpl" ) login := forms.New("login"). WithAction("/login"). Add( forms.Text("username").WithLabel("Username").WithRequired(), forms.Password("password").WithLabel("Password").WithRequired(), forms.Submit("submit").WithLabel("Sign in"), ) r := forms.NewRenderer("default", forms.ThemeDefault()) // The whole form as one HTML response (e.g. an HTMX partial swap) — // Renderer implements render.Renderer, so it composes with Buffer, // Cache, Gzip, and Multi like any other renderer. render.Buffer(r).Render(ctx, w, login, render.NoOptions) // Or embedded inside a larger page template: t := tmpl.HTML("myapp", tmpl.SetFuncsHTML(forms.Funcs(r))) // {{ form .Form }}
Two themes ship built in — ThemeDefault (dependency-free) and ThemeBootstrap5 — selected per call via the existing render.Options.Name field, exactly like named templates in tmpl:
r.RegisterTheme("bootstrap5", forms.ThemeBootstrap5()) r.Render(ctx, w, login, render.Options{Name: "bootstrap5"})
Redisplaying a form with the user's own input and validation errors after a failed submit is a single call — binding and validation themselves are deliberately out of scope (see the package doc):
login.Fill(req.PostForm, fieldErrs, formErrs...)
Every Render call accepts a context.Context. Cancellation is checked before rendering begins in all renderers.
ctx, cancel := context.WithCancel(context.Background()) defer cancel() render.JSON().Render(ctx, w, data, render.NoOptions)
Timeout is enforced by composable renderers (Buffer, Cache, Pipe, Multi, Markdown) via opts.Timeout, or by passing a context with a deadline:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() render.Buffer(render.JSON()).Render(ctx, w, data, render.NoOptions) // Or set the timeout inside Options (composable renderers only) render.Buffer(render.JSON()).Render(context.Background(), w, data, render.Options{ Timeout: 5 * time.Second, })
Implement two methods:
type MyRenderer struct{} func (r *MyRenderer) ContentType() string { return "application/x-custom" } func (r *MyRenderer) Render(ctx context.Context, w io.Writer, data any, opts render.Options) error { if err := render.CheckContext(ctx); err != nil { return err } _, cancel := render.ApplyTimeout(ctx, opts) defer cancel() _, err := io.WriteString(w, fmt.Sprint(data)) return err }
Run locally:
go test -bench=. -benchmem ./...Results on Apple M4, Go 1.26 (-count=3, struct payload, renderer pre-created):
| Renderer | ns/op | B/op | allocs/op |
|---|---|---|---|
| Text | 7 | 0 | 0 |
| HTML | 7 | 0 | 0 |
| Binary | 16 | 24 | 1 |
| JSON | 110 | 48 | 1 |
| JSON (parallel) | 31 | 48 | 1 |
| XML | 707 | 4 512 | 8 |
| CSV | 390 | 4 096 | 1 |
| YAML | 2 060 | 6 912 | 32 |
| Buffer(JSON) | 161 | 176 | 3 |
| Cache hit | 30 | 48 | 1 |
| HTML template | 630 | 384 | 13 |
| HTML template (parallel) | 215 | 385 | 13 |
The single alloc in JSON is json.NewEncoder. Text, HTML, and Binary are zero-allocation on the hot path. HTML templates use sync.Pool on clones — parallel throughput scales linearly.
This project is licensed under the BSD 3-Clause License. See the LICENSE file for details.