mono — making monoliths comfy
This site is made with
monoand 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.