SingleInstance and Standalone - two routing options that were missing

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.