Per-path SEO overrides without touching the content model

Content-type list pages have always had an SEO problem: the framework could not know what title or description to put on /posts or /essays because those pages have no single corresponding item. The answer was a per-path override table, slotted into the existing Head fallback chain without touching a single item type.

Per-path SEO overrides without touching the content model

The gap

Every content item in Smeldr can implement Head() — the framework calls it when rendering the detail page, the sitemap entry, and the RSS item. That covers /posts/my-article well. It covers /posts — the list page — poorly.

A list page has no single "item" to ask. The old fallback was SiteConfig.og_image plus whatever was in OGDefaults. On most sites that meant every list page shared the site-wide og:image and a generic title. Not great for a blog's /essays or a documentation section.

The alternatives were ListHeadFunc — a per-module Go function that returns a Head — and the global SiteConfig. ListHeadFunc works well but requires code: each module needs an option set at startup. There was no way to adjust an SEO override after deploy without a redeploy.

The solution: a database-backed override layer

PageMetaStore is a thin SQL table (smeldr_page_meta, four columns) that maps URL paths to SEO field overrides. Operators populate it through the MCP server — four Admin tools (set_page_meta, get_page_meta, delete_page_meta, list_page_meta) — or through App.GetPageMeta in a custom handler.

The store sits at a specific position in the fallback chain for list pages:

ListHeadFunc (Go code, highest priority)
  → PageMetaStore (DB-backed, operator-managed)
    → global SiteConfig / OGDefaults (lowest priority)

Detail pages are unchanged — they call Head() on the item itself, then fall back to SiteConfig. The override layer is list-page-specific because list pages are the ones without an owning item.

Three small design decisions

Get returns a zero value, not ErrNotFound. The caller pattern is: look up the path; if no override is stored, skip it. Returning an error for a missing row puts the error check at every call site. Returning a zero PageMeta and nil error means the caller checks meta.Path != "" — one field test, no error case. This follows the same convention as PageMeta{} being the meaningful empty value.

ListHeadFunc takes priority over the store. This is the code-over-data rule. If a developer has written a Go function that computes the list-page head, it knows more than a database row does. The store is for operators who cannot or should not redeploy; code wins when both are present.

INSERT OR REPLACE for upsert. SQLite's ON CONFLICT DO UPDATE (UPSERT) is cleaner in standard SQL but harder to write across drivers. INSERT OR REPLACE is SQLite-idiomatic, has identical semantics for this table (path is the primary key), and requires no extra clause. The store wraps smeldr.DB which already works with both SQLite and PostgreSQL; the PostgreSQL variant will migrate to ON CONFLICT DO UPDATE if pgx support is extended.

Wiring

// once at startup
smeldr.CreatePageMetaTable(db)
store := smeldr.NewPageMetaStore(db)
app.PageMeta(store)

// in mcp.go
srv := mcp.NewServer(cfg, mcp.WithPageMeta(db))

App.Handler() injects the store into every template module via the same push-loop pattern already used for nav trees and SEO defaults. No module needs to know the store exists — it just starts having its list-page head populated.

What this replaces

Nothing is removed. ListHeadFunc still works and takes priority. SiteConfig is still the last fallback. The new layer adds a knob that operators can turn from chat without touching the codebase. For sites where list-page SEO was an afterthought, it is now a two-minute fix.