Documentation
Core concepts

Content lifecycle

Every content item in Forge 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
Stateforge.Status constantVisible to public
Draftforge.DraftNo — 404
Scheduledforge.ScheduledNo — 404
Publishedforge.PublishedYes
Archivedforge.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 Forge 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. Forge 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

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

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

Forge 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(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
    log.Printf("published: %s — %s", ev.Type, ev.URL)
    return nil
})

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

All seven lifecycle signals are available:

SignalConstantFired when
After createforge.AfterCreateItem first persisted (any status)
After updateforge.AfterUpdateFields changed
After publishforge.AfterPublishTransitions to Published
After unpublishforge.AfterUnpublishLeaves Published
After archiveforge.AfterArchiveTransitions to Archived
After scheduleforge.AfterScheduleTransitions to Scheduled
After deleteforge.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 forge.On[T] as a module option. Before handlers can return an error to abort the operation.

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

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

    // Runs before publish — can abort
    forge.On(forge.BeforeUpdate, func(ctx forge.Context, p *Post) error {
        if p.Status == forge.Published && p.Cover.URL == "" {
            return forge.Err("cover", "required when publishing")
        }
        return nil
    }),
)
Hook typeAPIWhenCan abort?Receives
Before (module-level)forge.On[T]Before DB writeYes*T (typed)
After (bus)app.OnSignalAfter DB writeNoSignalEvent
After (module-level typed)forge.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 Forge will POST a signed JSON payload on every matching event. See Webhooks for setup and payload format.