- Go 83.1%
- HTML 9.3%
- CSS 5.1%
- Shell 1.8%
- Just 0.5%
- Other 0.2%
| .cms | ||
| cmd | ||
| content | ||
| internal | ||
| scripts | ||
| static/css | ||
| templates | ||
| .air.toml | ||
| .cms.toml | ||
| .gitignore | ||
| .goreleaser.yml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| README.md | ||
| TODO.md | ||
Go Headless CMS
A file-based headless CMS for static site generators — Zola, Hugo, Eleventy, Jekyll, and Astro.
It wraps your existing content directories with a REST API, a browser UI, and a CLI so you can read, write, and search content without leaving your editor.
Features
- Multi-SSG — auto-detects Zola, Hugo, Eleventy, Jekyll, or Astro.
Fallback
GenericAdapterhandles any Markdown project. - REST API — full CRUD for content and assets across sources and collections. List, filter, sort, paginate. Create / update / delete with raw or structured frontmatter.
- Git integration —
status,log,diff,commit,pull,pushper source (go-git, no system git required). Webhook endpoint for auto-pull on push. - CLI —
stet content|git|schema|configwith--source,--output json, sorting, and pagination. - Web UI — sidebar layout, Dashboard, Content browser, Editor, Media library, Git panel, Schema viewer, Settings. All htmx-powered — no page reloads.
- Full-text search — indexed across sources with relevance scoring, title/body/frontmatter snippets, tag filtering, and draft control.
- Schema inference — scans frontmatter per collection, infers field types, detects required fields. Schema regenerates on pull or on demand.
- Multi-source — manage multiple repos from one CMS instance.
- Single binary — templates and static assets embedded with
embed.FS. No runtime dependencies (bblot databases are self-contained). - Token auth — Bearer token (configurable) protects the API. Session-based auth for the web UI.
Quick Start
One-liner
curl -fsSL https://raw.githubusercontent.com/stet/stet/main/scripts/install.sh | sh
Docker
docker run -p 8080:8080 -v $(pwd):/content ghcr.io/stet/stet:latest
From source
git clone https://github.com/stet/stet
cd stet
just build # → bin/cms
Initialize a config
stet init
This writes a .cms.toml with sensible defaults (port 8080, auth off,
current directory as the default source).
Start the server
stet serve
# or: stet serve --port 9090 --host 127.0.0.1
Open http://localhost:8080 — you'll see the dashboard.
CLI
All commands accept --source / -s (source ID) and --output json.
Content
# List entries in a collection
stet content list posts --sort date --dir asc -n 20
# Get full content (frontmatter + body)
stet content get posts/hello-world.md --output json
# Create a new entry
echo "## Hello" | stet content new posts/new-post.md
# Edit body (reads from stdin)
cat new-body.md | stet content edit posts/hello-world.md
# Publish / unpublish (toggles draft: false/true)
stet content publish posts/hello-world.md
stet content unpublish posts/hello-world.md
# Delete
stet content delete posts/old-post.md
Git
stet git status
stet git log -n 10
stet git commit -m "Update posts"
stet git pull
stet git push
Schema
# Show inferred schema for all collections
stet schema show
# Show one collection
stet schema show --collection posts
# Regenerate by rescanning
stet schema regen
Config
# Show current config
stet config show
# Set a value
stet config set --key server.port --value 9090
stet config set --key auth.enabled --value true
stet config set --key auth.token --value "my-secret-token"
REST API
Base URL: http://localhost:8080/api/v1
Sources
GET /api/v1/sources
Content
GET /api/v1/sources/{sid}/collections/{coll}/content
GET /api/v1/sources/{sid}/collections/{coll}/content/{path}
POST /api/v1/sources/{sid}/collections/{coll}/content
PUT /api/v1/sources/{sid}/collections/{coll}/content/{path}
PATCH /api/v1/sources/{sid}/collections/{coll}/content/{path}
DELETE /api/v1/sources/{sid}/collections/{coll}/content/{path}
Query parameters for list:
| Param | Default | Description |
|---|---|---|
q |
— | Free-text filter on title |
sort |
date |
title, date, mod_time, word_count, path |
dir |
desc |
asc or desc |
page |
1 |
1-based page number |
per_page |
50 |
Items per page (max 200) |
raw |
false |
When true, returns full body + raw frontmatter |
drafts |
— | only (drafts only) or exclude (published only) |
Create content
POST /api/v1/sources/default/collections/posts/content
{
"path": "posts/new-post.md",
"body": "## Hello, world!\n\nThis is my first post.",
"frontmatter": {
"title": "Hello World",
"date": "2026-06-12",
"draft": false,
"tags": ["intro"]
},
"fm_type": "toml"
}
Assets
GET /api/v1/sources/{sid}/assets
GET /api/v1/sources/{sid}/assets/{path}
POST /api/v1/sources/{sid}/assets (multipart/form-data)
DELETE /api/v1/sources/{sid}/assets/{path}
Upload example:
curl -F "file=@photo.jpg" -F "path=uploads/photo.jpg" \
http://localhost:8080/api/v1/sources/default/assets
Search
GET /api/v1/search
| Param | Default | Description |
|---|---|---|
q |
— | Full-text query |
source |
— | Limit to source ID |
tag |
— | Filter by tag |
field |
— | title, body, fm, or empty (all fields) |
drafts |
— | only or exclude |
limit |
50 |
Max results (max 200) |
offset |
0 |
Pagination offset |
Response includes relevance-scored results with match snippets and match_on
indicating where the hit occurred.
Git
GET /api/v1/sources/{sid}/git/status
GET /api/v1/sources/{sid}/git/log?n=50&offset=0
GET /api/v1/sources/{sid}/git/diff?from=<hash>&to=<hash>
POST /api/v1/sources/{sid}/git/commit { "message": "...", "author_name": "...", "author_email": "..." }
POST /api/v1/sources/{sid}/git/pull { "remote": "origin", "branch": "main" }
POST /api/v1/sources/{sid}/git/push { "remote": "origin", "branch": "main" }
POST /api/v1/git/webhook/{sid} (forge webhook receiver)
Schema
GET /api/v1/sources/{sid}/collections/{coll}/schema
POST /api/v1/sources/{sid}/collections/{coll}/schema (regenerate)
Auth
When auth.enabled = true in .cms.toml, all /api/v1/* routes require
authentication. Provide a Bearer token:
curl -H "Authorization: Bearer <token>" http://localhost:8080/api/v1/sources
Configuration (.cms.toml)
[server]
port = 8080
host = "0.0.0.0"
[auth]
enabled = false
token = ""
admin_user = "admin"
admin_pass = "changeme"
[[sources]]
id = "default"
name = "My Blog"
path = "."
ssg = "auto" # auto | zola | hugo | eleventy | jekyll | astro
fm_type = "toml" # toml | yaml
branch = "main"
auth = "none" # none | https | ssh
auto_pull = false
color = "#2563eb"
is_default = true
[schema]
regen_on_pull = true
[ui]
theme = "auto" # auto | light | dark
editor = "codemirror" # codemirror | plain
Add more [[sources]] blocks for multi-site management.
Directory Structure
.
├── cmd/ # Cobra CLI: root, serve, init, content, git, schema, config
│ ├── root.go
│ ├── serve.go
│ ├── init.go
│ └── cli.go
├── internal/
│ ├── auth/ # Session management
│ ├── config/ # .cms.toml loading, defaults, migration
│ ├── content/ # Markdown + frontmatter parser (TOML, YAML)
│ ├── git/ # go-git wrapper: status, log, commit, pull, push, diff
│ ├── index/ # bbolt-backed content index + fsnotify live updates
│ ├── schema/ # Schema inference engine + bbolt store
│ ├── server/
│ │ ├── handlers.go # Page handlers (dashboard, editor, assets, git, etc.)
│ │ └── api/ # Chi REST API router + middleware
│ │ ├── router.go
│ │ ├── content.go
│ │ ├── git.go
│ │ ├── assets.go
│ │ ├── search.go
│ │ └── sources.go
│ ├── source/ # Source types
│ ├── ssg/ # SSG adapters: Zola, Hugo, Eleventy, Jekyll, Astro, Generic
│ └── ui/ # UI utilities
├── templates/ # Go html/template files (embedded)
├── static/ # CSS, JS (embedded)
├── scripts/
│ └── install.sh # curl | sh installer
├── main.go # Entrypoint, embed directives
├── justfile # Build automation
├── Dockerfile # Multi-stage scratch image
├── .goreleaser.yml # Multi-platform release config
└── .cms.toml # Your config (generated by init)
Development
# Run with hot reload
just dev
# Build
just build
# Run tests
just test
# Format & lint
just fmt
just lint
License
MIT