The problem
Every Forge module registered two routes: a list route and a per-item show route. That worked fine for collections, but single-item pages — homepage, about, contact, terms — didn't fit the pattern. The workaround was a stub list template that immediately redirected, or a custom handler that bypassed the module entirely. Neither felt right.
The second gap: landing pages and blog posts. A post at /posts/my-first-post is fine for a blog archive, but if you want the URL to be /my-first-post, there was no way to do it inside the module system. You had to wire a parallel set of routes by hand.
Both gaps are closed in v1.23.0.
What shipped
forge.SingleInstance() — singleton page modules
A module with SingleInstance() registers exactly one route: GET /{prefix}. It serves the first Published item directly — no list, no slug in the URL.
app.Content(forge.NewModule((*AboutPage)(nil),
forge.At("/about"),
forge.Repo(repo),
forge.SingleInstance(),
forge.MCP(forge.MCPRead, forge.MCPWrite),
))
// GET /about → serves the published AboutPage
// GET /about/{slug} → 404 (route not registered)
The MCP side adapts automatically: the list_about_pages tool is suppressed (there's nothing to list). get_about_page, update_about_page, publish_about_page and archive_about_page are present as normal.
Pattern: module prefix ≠ public URL
The most common use of SingleInstance is a homepage: the module lives at /homepage for admin and MCP access, but the public site is at /.
// Module at /homepage — admin and MCP surface
app.Content(forge.NewModule((*HomePage)(nil),
forge.Repo(homePageRepo),
forge.At("/homepage"),
forge.SingleInstance(),
forge.MCP(forge.MCPRead, forge.MCPWrite),
))
// Public route — custom handler reads the published record
app.Handle("GET /", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hps, _ := homePageRepo.FindAll(r.Context(), forge.ListOptions{
Status: []forge.Status{forge.Published},
})
hp := homePageDefaults()
for _, p := range hps {
if p.Slug == "home" {
hp = p
break
}
}
// render template with hp ...
}))
The module handles authoring and previewing. The handler handles serving. They don't step on each other.
forge.Standalone() — top-level slug URLs
A Standalone module keeps its list route (GET /{prefix}) unchanged, but registers individual items at GET /{slug} rather than GET /{prefix}/{slug}.
app.Content(forge.NewModule((*Post)(nil),
forge.At("/posts"),
forge.Repo(repo),
forge.Standalone(),
))
// GET /posts → list of Published posts
// GET /my-first-post → serves Post with slug "my-first-post"
// GET /posts/my-first-post → 404
Multiple Standalone modules coexist. Slug dispatch is first-match across registered Standalone modules. If no module claims the slug, the request falls through to the redirect handler (which 404s if no redirect rule matches).
Sitemaps use /{slug} rather than /{prefix}/{slug} automatically. If the module also uses forge.AIIndex(forge.AIDoc), the AI doc is at GET /{slug}/aidoc.
The json tags trap
This came up during the v1.23.0 rollout on forge-cms.dev and is worth documenting explicitly.
Any custom field on a Forge content type needs an explicit json:"snake_case" tag:
// Correct
type Post struct {
forge.Node
Title string `forge:"required" db:"title" json:"title"`
MetaDesc string `db:"meta_desc" json:"meta_desc"`
}
// Wrong — MCP operations silently return empty values for Title and MetaDesc
type Post struct {
forge.Node
Title string `forge:"required" db:"title"`
MetaDesc string `db:"meta_desc"`
}
Without the json tag, Go serialises the field using the PascalCase field name. The MCP tools send and receive "title" and "meta_desc". The mismatch is silent — update_post succeeds but the values don't stick.
forge.Node fields (ID, Slug, Status, etc.) are exempt — they are handled internally and already have the correct tags.
forge-mcp v1.10.2 — preview URL fix for SingleInstance
Before v1.10.2, create_preview_url always generated /{prefix}/{slug}?preview=token for every module. For SingleInstance modules that route doesn't exist — GET /about/my-slug returns 404.
v1.10.2 detects MCPMeta().SingleInstance and generates /{prefix}?preview=token instead. Normal modules are unchanged.
If you are running SingleInstance modules and forge-mcp < v1.10.2, preview links from the MCP server will 404. Upgrade to v1.10.2.
When to use each option
SingleInstance — one record of this type will ever be published, no list page is needed, content is managed via MCP or CLI.
Standalone — items should live at top-level URLs (/my-post not /posts/my-post), list page at /{prefix} is still useful, slug uniqueness across all Standalone modules is guaranteed by the data.
Neither option changes how the repository works — all CRUD operations, lifecycle transitions, and MCP tools function identically. The change is routing only.