Documentation
Core concepts

Content lifecycle

Every content item in Smeldr has a lifecycle. It is not optional, and it cannot be disabled. This is what guarantees that draft content never leaks to the public — not by convention, but by architecture.

The four states

Draft → Published
Draft → Scheduled → Published
Published → Archived
Statesmeldr.Status constantVisible to public
Draftsmeldr.DraftNo — 404
Scheduledsmeldr.ScheduledNo — 404
Publishedsmeldr.PublishedYes
Archivedsmeldr.ArchivedNo — 410 Gone

Why 410 for archived, not 404? A 410 Gone response tells search engines the content was intentionally removed. Google de-indexes 410 pages significantly faster than 404 pages. For a CMS, this is almost always what you want.

What Smeldr enforces automatically

DraftScheduledArchivedPublished
Public GET404404410200
Sitemapnononoyes
RSS feednononoyes
llms.txt / AIDocnononoyes
<meta robots>noindexnoindexnoindexindex
Author (own content)visiblevisiblevisiblevisible
Editor+visiblevisiblevisiblevisible

None of this requires configuration. Smeldr applies it to every registered content module.

Transitions via the HTTP API

Content transitions happen through standard PUT requests:

# Publish a draft
PUT /posts/my-post
{"status": "published"}

# Schedule for future publication
PUT /posts/my-post
{"status": "scheduled", "scheduled_at": "2026-06-01T09:00:00Z"}

# Archive a published post
PUT /posts/my-post
{"status": "archived"}

PublishedAt is set automatically when a post transitions to Published — whether immediately or via the scheduler. You cannot set it directly.

Scheduled publishing

Smeldr runs an internal scheduler. No external cron or queue required. When scheduled_at arrives, Smeldr:

1. Transitions the item to Published 2. Sets PublishedAt 3. Fires AfterPublish on the signal bus 4. Regenerates the sitemap and RSS feed

The scheduler is adaptive — it wakes up earlier when items are close to their scheduled time and backs off when nothing is due.

Slug stability and redirects

Smeldr tracks every URL a content item has ever had. Renaming a slug or changing a module prefix generates a 301 redirect automatically. You never need to manage redirects by hand.

EventOld URL response
Slug renamed301 → new URL
Module prefix changed301 → new prefix
Item archived410 Gone
Item deleted410 Gone

Subscribing to lifecycle events

Use app.OnSignal to react to lifecycle transitions in your application code:

app.OnSignal(smeldr.AfterPublish, func(ctx context.Context, ev smeldr.SignalEvent) error {
    log.Printf("published: %s — %s", ev.Type, ev.URL)
    return nil
})

app.OnSignal(smeldr.AfterArchive, func(ctx context.Context, ev smeldr.SignalEvent) error {
    return cache.Invalidate(ev.URL)
})

All seven lifecycle signals are available:

SignalConstantFired when
After createsmeldr.AfterCreateItem first persisted (any status)
After updatesmeldr.AfterUpdateFields changed
After publishsmeldr.AfterPublishTransitions to Published
After unpublishsmeldr.AfterUnpublishLeaves Published
After archivesmeldr.AfterArchiveTransitions to Archived
After schedulesmeldr.AfterScheduleTransitions to Scheduled
After deletesmeldr.AfterDeletePermanently deleted

Handlers are async, do not block HTTP responses, and run with a 100 ms timeout. Multiple handlers per signal are supported. See Signal bus for full details.

Module-level hooks (Before signals)

For hooks that need to intercept or modify a write operation before it completes, use smeldr.On[T] as a module option. Before handlers can return an error to abort the operation.

smeldr.NewModule((*Post)(nil),
    smeldr.At("/posts"),
    smeldr.Repo(repo),

    // Runs before create — can abort by returning an error
    smeldr.On(smeldr.BeforeCreate, func(ctx smeldr.Context, p *Post) error {
        p.Author = ctx.User().Name
        return nil
    }),

    // Runs before publish — can abort
    smeldr.On(smeldr.BeforeUpdate, func(ctx smeldr.Context, p *Post) error {
        if p.Status == smeldr.Published && p.Cover.URL == "" {
            return smeldr.Err("cover", "required when publishing")
        }
        return nil
    }),
)
Hook typeAPIWhenCan abort?Receives
Before (module-level)smeldr.On[T]Before DB writeYes*T (typed)
After (bus)app.OnSignalAfter DB writeNoSignalEvent
After (module-level typed)smeldr.On[T]After DB writeNo*T (typed)

Outbound webhooks

Webhook delivery is built on top of lifecycle signals. Register an HTTPS endpoint with App.Webhooks and Smeldr will POST a signed JSON payload on every matching event. See Webhooks for setup and payload format.