The Go web framework designed for how you actually think.
Built for developers. Optimized for AI. Zero compromises on readability.
v1.5.0 — stable. All exported symbols are stable. No breaking changes without a major version bump. See CHANGELOG.md.
app := forge.New(forge.Config{
BaseURL: "https://mysite.com",
Secret: []byte(os.Getenv("SECRET")),
})
app.Use(
forge.RequestLogger(),
forge.Recoverer(),
forge.SecurityHeaders(),
)
app.Content(&BlogPost{},
forge.At("/posts"),
forge.Auth(
forge.Read(forge.Guest),
forge.Write(forge.Author),
forge.Delete(forge.Editor),
),
forge.Cache(5*time.Minute),
forge.Social(forge.OpenGraph, forge.TwitterCard),
forge.AIIndex(forge.LLMsTxt, forge.AIDoc),
forge.Templates("templates/posts"),
)
app.Run(":8080")One block. A complete, production-ready content module with authentication, role-based access, SEO, social sharing, AI indexing, caching, and HTML templates.
Most frameworks make you learn the framework.
Forge makes you express your intent — and handles the rest.
The guarantee: Forge makes it impossible to do the common things wrong. Draft content never leaks. Cookies are never set without consent. SEO is never missing. AI crawlers always get clean data.
| Forge | Echo | Gin | Chi | |
|---|---|---|---|---|
| Zero dependencies | ✓ | ✗ | ✗ | ~ |
| Content lifecycle built-in | ✓ | ✗ | ✗ | ✗ |
| Draft-safe by default | ✓ | ✗ | ✗ | ✗ |
| SEO + structured data | ✓ | ✗ | ✗ | ✗ |
| AI indexing (llms.txt + AIDoc) | ✓ | ✗ | ✗ | ✗ |
| Cookie compliance built-in | ✓ | ✗ | ✗ | ✗ |
| Social sharing built-in | ✓ | ✗ | ✗ | ✗ |
| Role hierarchy built-in | ✓ | ✗ | ✗ | ✗ |
| AI-native endpoints (llms.txt, AIDoc) | ✓ | ✗ | ✗ | ✗ |
- Installation
- Getting started
- Core concepts
- Content types
- Lifecycle
- Roles & auth
- SEO & structured data
- AI indexing
- Social sharing
- Cookies & compliance
- Storage
- Middleware
- Templates & rendering
- Error handling
- Redirects & content mobility
- MCP integration (forge-mcp)
- The AI-first design philosophy
- Minimal complete example
go get github.com/forge-cms/forgeRequires Go 1.22+. No other dependencies.
Five minutes from go get to a running content API.
go get github.com/forge-cms/forge1. Define a content type
type Post struct {
forge.Node
Title string `forge:"required" json:"title"`
Body string `forge:"required,min=50" json:"body"`
}
func (p *Post) Head() forge.Head {
return forge.Head{
Title: p.Title,
Description: forge.Excerpt(p.Body, 160),
Canonical: forge.URL("/posts/", p.Slug),
}
}2. Wire it up
app := forge.New(forge.Config{
BaseURL: "https://mysite.com",
Secret: []byte(os.Getenv("SECRET")),
})
app.Content(&Post{},
forge.At("/posts"),
forge.Auth(
forge.Read(forge.Guest),
forge.Write(forge.Author),
forge.Delete(forge.Editor),
),
)
app.Run(":8080")3. You have:
GET /posts— list published posts (JSON or HTML)GET /posts/{slug}— single postPOST /posts— create (Author+)PUT /posts/{slug}— update (Author+)DELETE /posts/{slug}— delete (Author+)GET /posts/sitemap.xml— auto-generated, always fresh- Draft posts never visible to unauthenticated requests
No boilerplate. No route registration. No sitemap library.
Forge has six concepts. Learn them once, apply them everywhere.
Node → the base every content type embeds
Module → one content type, fully wired
Signal → a hook that fires when something changes
Head → all metadata for a page (SEO + social + AI)
Cookie → a declared, typed, compliance-aware browser cookie
Role → a position in the access hierarchy
Everything else is just Go.
Embed forge.Node. Implement Validate() and Head(). That's the contract.
type BlogPost struct {
forge.Node // ID, Slug, Status, timestamps
Title string `forge:"required" json:"title"`
Body string `forge:"required,min=50" json:"body"`
Author string `forge:"required" json:"author"`
Tags []string ` json:"tags,omitempty"`
Cover forge.Image ` json:"cover,omitempty"`
}
// Validate runs after struct-tag validation.
// Use it for rules that tags cannot express.
func (p *BlogPost) Validate() error {
if p.Status == forge.Published && len(p.Tags) == 0 {
return forge.Err("tags", "required when publishing")
}
return nil
}
// Head returns all metadata for this content's page.
// Forge uses this for SEO, social sharing, and AI indexing.
func (p *BlogPost) Head() forge.Head {
return forge.Head{
Title: p.Title,
Description: forge.Excerpt(p.Body, 160),
Author: p.Author,
Tags: p.Tags,
Image: p.Cover,
Type: forge.Article,
Canonical: forge.URL("/posts/", p.Slug),
Breadcrumbs: forge.Crumbs(
forge.Crumb("Home", "/"),
forge.Crumb("Posts", "/posts"),
forge.Crumb(p.Title, "/posts/"+p.Slug),
),
}
}
// Markdown enables AI-friendly content negotiation.
// Accept: text/markdown → returns this. Accept: text/plain → stripped version.
func (p *BlogPost) Markdown() string { return p.Body }type Node struct {
ID string // UUID — internal primary key
Slug string // URL-safe identifier, auto-generated from title
Status forge.Status // Draft | Published | Scheduled | Archived
PublishedAt time.Time // zero if not Published
ScheduledAt *time.Time // non-nil if Scheduled
CreatedAt time.Time
UpdatedAt time.Time
}Slug is auto-generated from the first forge:"required" string field
unless you set it explicitly. Renaming a slug is safe — the UUID
keeps all internal relations intact.
type Image struct {
URL string // absolute or relative
Alt string // required for accessibility and SEO
Width int // required for Open Graph
Height int // required for Open Graph
}Every content type has a lifecycle. Always. It cannot be opted out of — this is what guarantees draft content never leaks to the public, sitemaps, feeds, or AI crawlers.
forge.Draft // visible to Author+ (own) and Editor+
forge.Published // publicly visible
forge.Scheduled // publishes automatically at ScheduledAt
forge.Archived // hidden from public, preserved in storage| Draft | Scheduled | Archived | Published | |
|---|---|---|---|---|
| Public GET | 404 | 404 | 404 | ✓ |
| Sitemap | ✗ | ✗ | ✗ | ✓ |
| RSS feed | ✗ | ✗ | ✗ | ✓ |
| AIDoc / llms.txt | ✗ | ✗ | ✗ | ✓ |
<meta robots> |
noindex | noindex | noindex | index |
| Author (own content) | ✓ | ✓ | ✓ | ✓ |
| Editor+ | ✓ | ✓ | ✓ | ✓ |
✅ Available — the adaptive ticker and automatic
Scheduled → Publishedtransition are implemented as of Milestone 8.
Forge runs an internal ticker. No external cron needed.
// Schedule via the API
PUT /posts/my-draft
{
"status": "scheduled",
"scheduled_at": "2025-09-01T09:00:00Z"
}At scheduled_at, Forge automatically transitions to Published,
sets PublishedAt, fires AfterPublish signals,
regenerates the sitemap, and adds the item to the RSS feed.
Admin → full access including app configuration
Editor → create, update, delete any content — sees all drafts
Author → create, update own content — sees own drafts
Guest → read Published content only (unauthenticated)
Higher roles inherit all permissions below them.
forge.Write(forge.Author) means Author, Editor, and Admin.
// Create custom roles inline with the hierarchy builder
moderator := forge.NewRole("moderator", forge.RoleBelow(forge.Editor), forge.RoleAbove(forge.Author))
subscriber := forge.NewRole("subscriber", forge.RoleBelow(forge.Author), forge.RoleAbove(forge.Guest))
// Use anywhere a Role is accepted
app.Content(&BlogPost{},
forge.At("/posts"),
forge.Auth(forge.Read(subscriber), forge.Write(moderator)),
)// Accept bearer tokens (APIs, mobile clients)
app.Use(forge.Authenticate(forge.BearerHMAC(secret)))
// Accept cookie sessions (browser apps)
app.Use(forge.Authenticate(forge.CookieSession("forge_session", secret)))
// Accept both — first match wins
// Use this for apps that serve both a browser UI and an API
app.Use(forge.Authenticate(forge.AnyAuth(
forge.BearerHMAC(secret),
forge.CookieSession("forge_session", secret),
)))
// Generate a signed token
token := forge.SignToken(forge.User{
ID: "42",
Name: "Alice",
Roles: []forge.Role{forge.Editor},
}, secret)When multiple auth methods are configured, Forge tries them in order and uses the first that succeeds. A request with a valid Bearer token and no cookie is authenticated as a bearer user. A request with neither is treated as forge.Guest.
forge.On(forge.BeforeCreate, func(ctx forge.Context, p *BlogPost) error {
user := ctx.User() // forge.User{ID, Name, Roles}
user.HasRole(forge.Editor) // true if Editor or above
user.Is(forge.Author) // true if exactly Author
return nil
})Define metadata once on your content type. Forge renders it correctly everywhere — HTML head, JSON-LD, sitemap, RSS, and AI endpoints.
func (p *BlogPost) Head() forge.Head {
return forge.Head{
Title: p.Title,
Description: forge.Excerpt(p.Body, 160),
Author: p.Author,
Published: p.PublishedAt,
Modified: p.UpdatedAt,
Image: p.Cover,
Type: forge.Article,
Canonical: forge.URL("/posts/", p.Slug),
Breadcrumbs: forge.Crumbs(
forge.Crumb("Home", "/"),
forge.Crumb("Posts", "/posts"),
forge.Crumb(p.Title, "/posts/"+p.Slug),
),
}
}When Head() on your content type is not enough — for example, you need request
context like the site name, a per-request user preference, or a database lookup —
use HeadFunc. It receives the full forge.Context alongside the item.
HeadFunc takes priority over Headable when both are present.
// HeadFunc wins over the content type's Head() method when set
app.Content(&BlogPost{},
forge.At("/posts"),
forge.HeadFunc(func(ctx forge.Context, p *BlogPost) forge.Head {
return forge.Head{
Title: p.Title + " — " + ctx.SiteName(),
}
}),
)forge.Article // blog posts, news articles
forge.Product // e-commerce products
forge.FAQPage // FAQ pages
forge.HowTo // step-by-step guides
forge.Event // events with dates and locations
forge.Recipe // recipes with ingredients
forge.Review // reviews with ratings
forge.Organization // company / about pagesapp.SEO(forge.SitemapConfig{
ChangeFreq: forge.Weekly,
Priority: 0.8,
})Each module owns its fragment (e.g. /posts/sitemap.xml).
Forge merges all fragments into /sitemap.xml automatically.
Sitemaps regenerate on every publish/unpublish — never stale, never on-demand.
app.SEO(forge.RobotsConfig{
Disallow: []string{"/admin"},
Sitemaps: true,
AIScraper: forge.AskFirst, // respectful AI crawler policy
})✅ Available —
/llms.txt, AIDoc endpoints, and content negotiation for AI agents.
Forge is the first Go framework to treat AI indexing as a first-class feature.
Forge generates /llms.txt automatically from all registered modules.
Only Published content appears. Regenerated on every publish.
app.Content(&BlogPost{},
forge.At("/posts"),
forge.AIIndex(forge.LLMsTxt),
)Enable all three AI endpoints in one call:
app.Content(&BlogPost{},
forge.At("/posts"),
forge.AIIndex(forge.LLMsTxt, forge.LLMsTxtFull, forge.AIDoc),
)Override with a custom template by creating templates/llms.txt:
# {{.SiteName}}
> {{.Description}}
## Posts
{{forge_llms_entries .}}
Every Published content item gets a /{prefix}/{slug}/aidoc endpoint.
Designed to be token-efficient and unambiguous for LLMs.
+++aidoc+v1+++
type: article
id: 019242ab-1234-7890-abcd-ef0123456789
slug: hello-world
title: Hello World
author: Alice
created: 2025-01-15
modified: 2025-03-01
tags: [intro, welcome]
summary: A short introduction to this blog.
+++
Full body content here — clean, stripped of HTML.
The format is designed for token efficiency:
statusis omitted — AIDoc endpoints only serve Published content- Dates use
YYYY-MM-DD— time and timezone are rarely meaningful for AI consumers - Responses are gzip-compressed at the transport layer — no token cost, significant network saving for bulk crawling
- No binary encoding — LLMs read this directly without preprocessing
+++aidoc+v1+++ allows future evolution without breaking existing parsers.
# JSON (default)
curl /posts/hello-world
# HTML (when templates registered)
curl /posts/hello-world -H "Accept: text/html"
# Clean markdown (requires Markdown() method on content type)
curl /posts/hello-world -H "Accept: text/markdown"
# Clean plain text (always available)
curl /posts/hello-world -H "Accept: text/plain"No configuration. Forge handles negotiation automatically.
✅ Available —
forge.Social(), Open Graph, and Twitter Card rendering.
func (p *BlogPost) Head() forge.Head {
return forge.Head{
Title: p.Title,
Description: forge.Excerpt(p.Body, 160),
Image: p.Cover, // used for og:image and twitter:image
// Per-platform overrides (optional)
Social: forge.SocialOverrides{
Twitter: forge.TwitterMeta{
Card: forge.SummaryLargeImage,
Creator: "@alice",
},
},
}
}app.Content(&BlogPost{},
forge.At("/posts"),
forge.Social(forge.OpenGraph, forge.TwitterCard),
)Forge renders in <head>:
<meta property="og:title" content="Hello World" />
<meta property="og:description" content="..." />
<meta property="og:image" content="https://mysite.com/img/cover.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:type" content="article" />
<meta property="og:url" content="https://mysite.com/posts/hello-world" />
<meta property="article:published_time" content="2025-01-15T09:00:00Z" />
<meta property="article:author" content="Alice" />
<meta property="article:tag" content="intro" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Hello World" />
<meta name="twitter:creator" content="@alice" />✅ Available — typed cookie declarations, consent enforcement, and
/.well-known/cookies.jsonare implemented as of Milestone 6.
Forge treats cookies as typed, declared, compliance-aware values. The category determines which API you can use — enforced at compile time. It is architecturally impossible to set a non-necessary cookie without consent handling.
var (
// Necessary — use forge.SetCookie, no consent needed
SessionCookie = forge.Cookie{
Name: "forge_session",
Category: forge.Necessary,
Duration: 24 * time.Hour,
HTTPOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Purpose: "Authenticates the current user session.",
}
// Non-necessary — must use forge.SetCookieIfConsented
PreferenceCookie = forge.Cookie{
Name: "forge_prefs",
Category: forge.Preferences,
Duration: 365 * 24 * time.Hour,
Secure: true,
SameSite: http.SameSiteLaxMode,
Purpose: "Remembers theme and language preferences.",
}
)// Necessary — always works
forge.SetCookie(w, r, SessionCookie, sessionID)
value, ok := forge.ReadCookie(r, SessionCookie)
forge.ClearCookie(w, SessionCookie)
// Non-necessary — silently skipped if user has not consented
set := forge.SetCookieIfConsented(w, r, PreferenceCookie, "dark-mode")forge.Necessary // session auth, CSRF — never requires consent
forge.Preferences // theme, language — requires consent
forge.Analytics // page views, funnels — requires consent
forge.Marketing // ad targeting — requires consent// Default — public (compliance transparency by design)
app.Cookies(SessionCookie, PreferenceCookie)
// Restricted — require Editor+ to read the manifest
app.Cookies(SessionCookie, PreferenceCookie,
forge.ManifestAuth(forge.Editor),
)Forge serves a live manifest at GET /.well-known/cookies.json.
Any developer or AI agent can audit your cookie compliance with a single request.
{
"generated": "2025-06-01T00:00:00Z",
"cookies": [
{
"name": "forge_session",
"category": "necessary",
"duration": "24h",
"purpose": "Authenticates the current user session.",
"consent": false
},
{
"name": "forge_prefs",
"category": "preferences",
"duration": "8760h",
"purpose": "Remembers theme and language preferences.",
"consent": true
}
]
}Forge accepts any database connection that satisfies the forge.DB interface —
which *sql.DB and any pgx adapter already implement.
You write SQL. Forge handles scanning and mapping.
Forge core has zero dependencies. The driver is always your choice. Performance is the default recommendation — zero-dependency is the alternative.
Recommended — pgx via stdlib shim (~1.8× faster than lib/pq)
For most PostgreSQL users. One dependency, near-native pgx speed,
compatible with all standard *sql.DB tooling.
import "github.com/jackc/pgx/v5/stdlib"
db := stdlib.OpenDB(connConfig) // *sql.DB backed by pgx
app := forge.New(forge.Config{DB: db, ...})Maximum performance — native pgx connection pool (~2.5× faster)
For high-throughput production workloads. Uses forge-pgx,
a thin adapter that is a separate module from Forge core.
import (
forgepgx "github.com/forge-cms/forge-pgx"
"github.com/jackc/pgx/v5/pgxpool"
)
pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
app := forge.New(forge.Config{DB: forgepgx.Wrap(pool), ...})Zero dependencies — standard database/sql
For SQLite, MySQL, or teams that cannot add any dependency. Swap the driver without changing any other Forge code.
import (
"database/sql"
_ "github.com/mattn/go-sqlite3" // SQLite
// _ "github.com/go-sql-driver/mysql" // MySQL
// _ "github.com/lib/pq" // PostgreSQL (slower than pgx)
)
db, _ := sql.Open("sqlite3", "./mysite.db")
app := forge.New(forge.Config{DB: db, ...})Switching between all three approaches requires changing exactly one value
in forge.Config. Nothing else in your codebase changes.
// Single item — returns typed result, maps columns to struct fields
post, err := forge.QueryOne[*BlogPost](db,
"SELECT * FROM posts WHERE slug = $1 AND status = $2",
slug, forge.Published,
)
if errors.Is(err, forge.ErrNotFound) {
// no row — use forge.ErrNotFound, not sql.ErrNoRows
}
// List with pagination
opts := forge.ListOptions{Page: 1, PerPage: 20, OrderBy: "published_at", Desc: true}
posts, err := forge.Query[*BlogPost](db,
"SELECT * FROM posts WHERE status = $1 ORDER BY published_at DESC LIMIT $2 OFFSET $3",
forge.Published, opts.PerPage, opts.Offset(),
)Forge maps columns to struct fields by db tag first, then by field name.
No ORM. No query builder. SQL is the query language — and AI assistants write it extremely well.
type BlogPost struct {
forge.Node
Title string `forge:"required" db:"title" json:"title"`
Body string `forge:"required" db:"body" json:"body"`
Author string `forge:"required" db:"author" json:"author"`
}
// db tag controls column mapping — omit it and Forge uses the field name lowercasedFor testing, prototyping, and custom backends:
type Repository[T any] interface {
FindByID(ctx context.Context, id string) (T, error)
FindBySlug(ctx context.Context, slug string) (T, error)
FindAll(ctx context.Context, opts forge.ListOptions) ([]T, error)
Save(ctx context.Context, node T) error
Delete(ctx context.Context, id string) error
}
// Zero-config in-memory implementation
repo := forge.NewMemoryRepo[*BlogPost]()✅ Available —
SQLRepo[T]is a production-readyRepository[T]backed byforge.DB, implemented as of Milestone 7.
SQLRepo[T] derives the table name automatically (BlogPost → blog_posts) or accepts a Table() override:
// Auto-derived table name: blog_posts
repo := forge.NewSQLRepo[*BlogPost](db)
// Explicit table name
repo := forge.NewSQLRepo[*BlogPost](db, forge.Table("posts"))
// T must be a pointer type — NewSQLRepo[*BlogPost] pairs with NewModule((*BlogPost)(nil), ...)
repo := forge.NewSQLRepo[*BlogPost](db)
m := forge.NewModule((*BlogPost)(nil),
forge.At("/posts"),
forge.Repo(repo),
)
app.Content(m)SQLRepo uses $N positional placeholders (PostgreSQL / pgx compatible) and upserts via ON CONFLICT (id) DO UPDATE.
app.Use(
forge.RequestLogger(), // structured slog output
forge.Recoverer(), // panic → 500, process never crashes
forge.CORS("https://mysite.com"), // CORS headers
forge.MaxBodySize(1 << 20), // 1 MB request limit
forge.RateLimit(100, time.Minute), // 100 req/min per IP
forge.SecurityHeaders(), // HSTS, CSP, X-Frame-Options, Referrer-Policy
)app.Content(&BlogPost{},
forge.At("/posts"),
forge.Middleware(
forge.InMemoryCache(5*time.Minute), // LRU, max 1000 entries
// forge.InMemoryCache(5*time.Minute, forge.CacheMaxEntries(500)), // custom limit
myCustomMiddleware,
),
)Standard http.Handler wrapping — no Forge-specific types required:
func myCustomMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := forge.ContextFrom(w, r) // access forge.Context if needed
_ = ctx.User()
next.ServeHTTP(w, r)
})
}Forge selects the response format from the Accept header.
Register templates to enable HTML. Everything else works automatically.
Accept: application/json → JSON (always available)
Accept: text/html → HTML template (requires forge.Templates)
Accept: text/markdown → raw markdown (requires Markdown() method)
Accept: text/plain → clean text (always available)
app.Content(&BlogPost{},
forge.At("/posts"),
forge.Templates("templates/posts"), // parsed at startup, fails fast if missing
// forge.TemplatesOptional("templates/posts"), // no startup failure if missing
// Forge looks for:
// templates/posts/list.html → GET /posts
// templates/posts/show.html → GET /posts/{slug}
){{/* show.html */}}
{{template "forge:head" .}}
<article>
<h1>{{.Content.Title}}</h1>
<p>By {{.Content.Author}} · {{.Content.PublishedAt | forge_date}}</p>
{{.Content.Body | forge_markdown}}
</article>
{{/* list.html */}}
{{template "forge:head" .}}
{{range .Content}}
<a href="/posts/{{.Slug}}">
<h2>{{.Title}}</h2>
<p>{{.Body | forge_excerpt 120}}</p>
</a>
{{end}}The forge:head partial renders everything in <head> automatically:
<title>, <meta>, canonical, Open Graph, Twitter Cards, twitter:site,
app-level JSON-LD, JSON-LD, breadcrumbs,
and <meta name="robots"> based on content Status.
type TemplateData[T Node] struct {
Content T // T for show, []T for list
Head forge.Head // from Headable.Head() on T, or HeadFunc if provided (HeadFunc takes priority)
User forge.User // current user (zero value if Guest)
Request *http.Request
OGDefaults *forge.OGDefaults // app-level OG/Twitter fallbacks (nil if not configured)
AppSchema template.HTML // pre-rendered app-level JSON-LD block (empty if not configured)
HeadAssets *forge.HeadAssets // preconnect/stylesheets/favicons/scripts (nil if not configured)
}✅ Available
Define nav, footer, or any shared HTML once and inject it into every module template set — and into custom handler templates — automatically.
app.Partials("templates/partials") // any *.html file in the directory is a partialEach partial file must use {{define "name"}}...{{end}} syntax (same convention
as forge:head). Files are loaded in alphabetical order for determinism.
{{/* templates/partials/nav.html */}}
{{define "nav"}}
<nav><a href="/">Home</a> | <a href="/posts">Blog</a></nav>
{{end}}{{/* templates/posts/list.html */}}
{{template "nav" .}}
{{range .Content}}<a href="/posts/{{.Slug}}"><h2>{{.Title}}</h2></a>{{end}}For custom app.Handle() routes that also need shared partials, use
App.MustParseTemplate:
// parsed once at startup — includes TemplateFuncMap, forge:head, and all partials
homeTpl := app.MustParseTemplate("templates/home.html")
app.Handle("GET /", func(w http.ResponseWriter, r *http.Request) {
data := forge.NewTemplateData[any](ctx, nil, forge.Head{Title: "Home"}, "My Site")
homeTpl.Execute(w, data)
})MustParseTemplate panics on error (consistent with MustConfig), so
misconfigured templates fail at startup rather than at first request.
✅ Available
Custom handler data structs can embed forge.PageHead to gain
{{template "forge:head" .}} support without using TemplateData[T]:
type homeData struct {
forge.PageHead // promotes Head, OGDefaults, AppSchema, HeadAssets
Posts []*Post
Featured *Post
}
homeTpl := app.MustParseTemplate("templates/home.html")
app.Handle("GET /", func(w http.ResponseWriter, r *http.Request) {
data := homeData{
PageHead: forge.PageHead{Head: forge.Head{Title: "Home"}},
Posts: loadPosts(),
}
homeTpl.Execute(w, data)
})In templates/home.html:
<head>{{template "forge:head" .}}</head>
<body>
{{range .Posts}}<h2>{{.Title}}</h2>{{end}}
</body>PageHead is the same struct that TemplateData[T] embeds internally.
Any field on PageHead set by app.SEO(...) (such as HeadAssets) must be
populated manually in custom handlers — module templates receive these
automatically, but app.Handle() routes are outside the module render path.
✅ Available
Pass additional data — sidebar items, navigation trees, related posts — into a module's list or show template without writing a custom handler:
app.Content(forge.NewModule((*DocPage)(nil),
forge.At("/docs"),
forge.Repo(docRepo),
forge.Templates("templates/docs"),
forge.ContextFunc(func(ctx forge.Context, _ any) (any, error) {
return docRepo.FindAll(ctx, forge.ListOptions{
Status: []forge.Status{forge.Published},
})
}),
))The return value is available as .Extra in the template:
{{- $nav := .Extra}}
<nav>
{{range $nav}}<a href="/docs/{{.Slug}}">{{.Title}}</a>{{end}}
</nav>The item argument is the content being rendered — T for show, []T for
list. Cast it inside the function if the concrete type is needed. Errors from
ContextFunc log and set .Extra to nil; the render is never aborted.
✅ Available
Inject preconnect hints, stylesheets, favicon links, and scripts into forge:head
on every page via app.SEO:
app.SEO(&forge.HeadAssets{
Preconnect: []string{"https://fonts.googleapis.com"},
Stylesheets: []string{
"https://fonts.googleapis.com/css2?family=Inter&display=swap",
"/static/app.css",
},
Favicons: []forge.FaviconLink{
{Rel: "icon", Type: "image/png", Sizes: "32x32", Href: "/favicon-32.png"},
{Rel: "apple-touch-icon", Href: "/apple-touch-icon.png"},
},
Scripts: []forge.ScriptTag{
{Src: "/static/app.js", Defer: true},
},
})Assets are emitted in order: preconnect → stylesheets → favicons → scripts.
Inline script bodies use template.JS to opt in to verbatim emission:
forge.ScriptTag{Body: template.JS("console.log('Forge')")} // never pass user input hereForge uses a typed error hierarchy. Every error knows its HTTP status, its machine-readable code, and what is safe to show the client. Internal details are logged — never leaked.
forge.ErrNotFound // 404 — resource does not exist
forge.ErrGone // 410 — resource existed but was intentionally removed
forge.ErrForbidden // 403 — authenticated but insufficient role
forge.ErrUnauth // 401 — not authenticated
forge.ErrConflict // 409 — state conflict (e.g. duplicate slug)forge.On(forge.BeforeCreate, func(ctx forge.Context, p *BlogPost) error {
if slugExists(p.Slug) {
return forge.ErrConflict // → 409
}
if !ctx.User().HasRole(forge.Editor) {
return forge.ErrForbidden // → 403
}
return forge.Err("title", "already taken") // → 422 with field detail
})// Accept: application/json
{
"error": {
"code": "validation_failed",
"message": "Validation failed",
"request_id": "019242ab-1234-7890-abcd-ef0123456789",
"fields": [
{ "field": "title", "message": "required" },
{ "field": "body", "message": "minimum 50 characters" }
]
}
}HTML error pages are rendered from templates/errors/{status}.html if present.
Every request gets a X-Request-ID header (UUID v7).
The same ID appears in error responses and every structured log entry —
making it trivial to trace a user-reported error to an exact log line.
// Available in all hooks and handlers
id := ctx.RequestID()✅ Available — manual redirects (
app.Redirect), prefix rewrites (Redirects(From(...))), 410 Gone, chain collapse, and/.well-known/redirects.jsonare implemented as of Milestone 7.
Forge automatically tracks every URL a piece of content has ever had. Rename a slug, change a prefix, archive a post — inbound links and SEO rankings are preserved without any developer effort.
| Event | Previous URL response |
|---|---|
| Slug renamed | 301 → new URL |
| Module prefix changed | 301 → new prefix + slug |
| Node archived | 410 Gone |
| Node deleted | 410 Gone |
| Node drafted / scheduled | 404 (does not leak existence) |
410 Gone 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.
// Bulk redirect when renaming a module prefix
app.Content(&BlogPost{},
forge.At("/articles"), // new prefix
forge.Redirects(forge.From("/posts"), "/articles"), // 301 all /posts/* → /articles/*
)
// One-off redirects
app.Redirect("/old-path", "/new-path", forge.Permanent) // 301
app.Redirect("/removed", "", forge.Gone) // 410To persist redirects across restarts, create the forge_redirects table and
call Load at startup:
CREATE TABLE forge_redirects (
from_path TEXT PRIMARY KEY,
to_path TEXT NOT NULL DEFAULT '',
code INTEGER NOT NULL DEFAULT 301,
is_prefix BOOLEAN NOT NULL DEFAULT FALSE
);if err := app.RedirectStore().Load(ctx, db); err != nil {
log.Fatal(err)
}GET /.well-known/redirects.json (requires Editor+)
✅ Available
forge-mcp is a separate module that wraps a forge.App and exposes its
content modules to AI assistants via the Model Context Protocol.
Schema derivation, lifecycle enforcement, and role checks are all automatic —
no configuration beyond forge.MCP(...) on your existing modules.
import forgemcp "github.com/forge-cms/forge/forge-mcp"
func main() {
app := forge.New(forge.MustConfig(forge.Config{
BaseURL: "https://mysite.com",
Secret: []byte(os.Getenv("SECRET")),
}))
app.Content(
forge.NewModule((*Post)(nil), forge.At("/posts"), forge.MCP(forge.MCPWrite)),
)
forgemcp.New(app).ServeStdio(context.Background(), os.Stdin, os.Stdout)
}For Claude Desktop, Cursor, and SSE remote transport configuration see forge-mcp/README.md.
Forge is the first Go framework explicitly designed to be maintained by AI assistants.
Intent over mechanics
forge.SEO(forge.RichArticle) — not 40 lines of JSON-LD template code.
An AI assistant reads, modifies, and explains your intent without touching internals.
Declarative over imperative
Every content module is fully described by its app.Content(...) call.
No tracing middleware chains. No hunting through files for route registration.
Impossible to get wrong by accident
Draft content cannot leak. Non-necessary cookies cannot be set without consent handling.
These are architectural guarantees, not conventions.
Self-describing
GET /.well-known/cookies.json → cookie compliance audit
GET /llms.txt → site structure for AI crawlers
GET /posts/hello-world.aidoc → token-efficient content for LLMs
GET /sitemap.xml → always fresh, event-driven
One right way
One way to declare cookies. One way to handle SEO. One way to register content.
AI assistants never guess which pattern you used.
Consistent naming
Every exported symbol: forge.Verb(Noun) or forge.Noun.
No abbreviations. No clever names. Predictable, searchable, memorable.
package main
import (
"os"
"time"
"github.com/forge-cms/forge"
)
type Article struct {
forge.Node
Title string `forge:"required" json:"title"`
Body string `forge:"required,min=100" json:"body"`
Author string `forge:"required" json:"author"`
Cover forge.Image ` json:"cover,omitempty"`
}
func (a *Article) Validate() error {
if a.Status == forge.Published && a.Cover.URL == "" {
return forge.Err("cover", "required when publishing")
}
return nil
}
func (a *Article) Head() forge.Head {
return forge.Head{
Title: a.Title,
Description: forge.Excerpt(a.Body, 160),
Author: a.Author,
Image: a.Cover,
Type: forge.Article,
Canonical: forge.URL("/articles/", a.Slug),
}
}
func (a *Article) Markdown() string { return a.Body }
func main() {
secret := []byte(os.Getenv("SECRET"))
app := forge.New(forge.Config{
BaseURL: "https://mysite.com",
Secret: secret,
})
app.Use(
forge.RequestLogger(),
forge.Recoverer(),
forge.SecurityHeaders(),
forge.MaxBodySize(1 << 20),
forge.Authenticate(forge.AnyAuth(
forge.BearerHMAC(secret),
forge.CookieSession("session", secret),
)),
)
app.SEO(forge.SitemapConfig{ChangeFreq: forge.Weekly, Priority: 0.8})
app.SEO(forge.RobotsConfig{AIScraper: forge.AskFirst})
app.Content(&Article{},
forge.At("/articles"),
forge.Auth(
forge.Read(forge.Guest),
forge.Write(forge.Author),
forge.Delete(forge.Editor),
),
forge.Cache(10*time.Minute),
forge.Social(forge.OpenGraph, forge.TwitterCard),
forge.AIIndex(forge.LLMsTxt, forge.AIDoc),
forge.Templates("templates/articles"),
forge.On(forge.BeforeCreate, func(ctx forge.Context, a *Article) error {
a.Author = ctx.User().Name
return nil
}),
)
app.Run(":8080")
}~70 lines. What you get:
Full CRUD · Role-based auth · Draft-safe lifecycle
Structured data (JSON-LD) · Event-driven sitemap · Content negotiation
Open Graph · Twitter Cards · AI indexing · RSS feed
Security headers · Graceful shutdown · Cookie compliance manifest
Scheduled publishing
Windows: CSS files served as text/plain
Go's MIME type lookup on Windows uses the registry, which may map .css
to text/plain. If your browser rejects stylesheets during local development,
add this to your main() before starting the server:
import "mime"
mime.AddExtensionType(".css", "text/css")AGPL v3 — free for individuals, open source projects, and companies building their own sites. A commercial license will be available for organisations running Forge as a hosted service. See COMMERCIAL.md.