mono — making monoliths comfy

This site is made with mono and love!

mono is a tiny Go web framework built around a file-system router. Drop files in a directory, get a working site — with TLS, Tailwind, gzip, rate limiting, and security headers included.

This page is index.md inside ./app/mono/. That’s all it took to create it.


Getting started

The whole server is a single fluent chain. The file-system router and custom API handlers live side by side — no special wiring, no separate router config:

mono.New(mono.Config{
Tls:             true,
Domains:         []string{"example.com"},
TlsContactEmail: "you@example.com",
}).
Endpoint(mono.React("./app")). // entire ./app directory as pages
Endpoint(MyApiHandler(), "/api/data"). // custom handler, same cost as a page
Endpoint(AnotherHandler(), "/api/x").  // add as many as you like
Middleware(
mono.MiddlewareRpsLimitPerIP(10, 50),
mono.MiddlewareCompressor,
mono.MiddlewareCachePolicy(0, time.Hour),
).
Start()

A handler is just a function — no framework-specific types to implement, no registration ceremony:

func MyApiHandler() mono.HandlerFunc {
return func (ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
rw.Header().Set("Content-Type", "application/json")
return json.NewEncoder(rw).Encode(map[string]string{"ok": "yes"})
}
}

File-system routing

mono.React("./app") walks your ./app directory and turns every folder into a URL. Each directory can contain:

File What it does
index.html Raw HTML, served as-is
index.gohtml Go template, executed at build time
index.md Markdown, Go template preprocessing supported
layout.yaml Wraps child pages in a shared HTML shell

Subdirectories inherit their parent’s layout.yaml automatically. A .mono file in a directory disables rendering for that subtree.


layout.yaml

layout.yaml defines the HTML shell that wraps every page in its directory tree.

title: "my-page | mysite"   # Go template syntax works here too
icon: ./favicon.ico
meta:
  description: "Hello."
head:
  - "..."                           # template functions work here too
body:
  - "<nav>...</nav>"
  - [ "<main>", "</main>" ]          # pair: wraps body func
components:
  nav_menu: _components/nav.gohtml         # built once at startup
  breadcrumbs:
    source: _components/breadcrumbs.gohtml
    dynamic: true                          # re-executed per page

dynamic: true components are re-rendered for every page they appear in, so they can use url or rel to know which page they’re on. Static components are rendered once and reused.


Template functions

All functions are called with {{funcName args...}}. Available everywhere in .gohtml and .md files:

Function Description
url Absolute URL of the current page (/poe/stats)
rel Path relative to the app root (poe/stats)
absolute "./file.js" Resolve a path relative to the current directory
file_src path "mime/type" Content-addressed URL for a static asset — safe to cache forever
auto_dark_mode FOUC-preventing dark mode script for <head>
minihtmx Inlines the bundled mini-htmx script
script_inline "./file.js" Inlines a JS file as a script tag
dict "Key" value ... Build a map[string]any inline
list a b c Build a []any inline
base, join, split, trim, sub String and math helpers

Adding an API endpoint

Any mono.HandlerFunc can be mounted alongside the file-system router:

func MyHandler() mono.HandlerFunc {
return func (ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
rw.Header().Set("Content-Type", "application/json")
return json.NewEncoder(rw).Encode(map[string]string{"ok": "yes"})
}
}

mono.New(mono.Config{Tls: true, Domains: []string{"example.com"}, TlsContactEmail: "you@example.com"}).
Endpoint(mono.React("./app")).
Endpoint(MyHandler(), "/api/my-endpoint").
Start()

mono.Folder("./downloads") and mono.Static("./file.zip") serve files or whole directories.


Middleware

Middleware wraps all handlers and chains left to right:

mono.New(...).
Endpoint(...).
Middleware(
mono.MiddlewareRpsLimitPerIP(10, 50), // 10 req/s, burst 50, per IP
mono.MiddlewareCompressor,            // gzip for non-media responses
mono.MiddlewareCachePolicy(0, time.Hour), // no-cache HTML, 1h media
).
Start()
Middleware What it does
MiddlewareRpsLimitPerIP(rps, burst) Per-IP token bucket; media requests cost 5×
MiddlewareRpsLimitPerIPWhitelistPaths(paths...) Predicate to exempt paths from rate limiting
MiddlewareCompressor Gzip responses (skips images, video, fonts, archives)
MiddlewareCachePolicy(html, media) Sets Cache-Control; zero duration = no-store
MiddlewareHeaders(k, v, ...) Injects arbitrary response headers
MiddlewareOnErrorLog Logs handler errors and panics via slog

Security headers (CSP, HSTS, X-Frame-Options, etc.) are added automatically by mono.New().


TLS

Pass Tls: true and a domain list — mono handles Let’s Encrypt via autocert, including www. redirects. Certs are cached in /tmp/monotonic by default (TlsCacheDir to override).


Why mono exists

mono is inspired by Next.js — the DX was great, the performance was not. So the same ideas were rebuilt in Go: this site now compiles >200× faster and idles at 8 MB RAM instead of 2 GB. All of it fits in <4k lines of code.