A simple, opinionated Go framework for building micro-SaaS applications.
Forge is designed around the principle of "no magic" — it uses explicit, readable code with no reflection or service containers. The framework provides a thin orchestration layer while keeping business logic in plain Go handlers.
go get github.com/dmitrymomot/forgepackage main import ( "log" "github.com/dmitrymomot/forge" ) func main() { app := forge.New( forge.AppConfig{}, forge.WithHandlers(&MyHandler{}), ) if err := forge.Run( forge.RunConfig{Address: ":8080"}, forge.WithFallback(app), ); err != nil { log.Fatal(err) } } type MyHandler struct{} func (h *MyHandler) Routes(r forge.Router) { r.GET("/", h.index) } func (h *MyHandler) index(c forge.Context) error { return c.JSON(200, map[string]string{"message": "Hello, World!"}) }Handlers implement the Handler interface to declare routes:
type AuthHandler struct { repo *repository.Queries } func NewAuth(repo *repository.Queries) *AuthHandler { return &AuthHandler{repo: repo} } func (h *AuthHandler) Routes(r forge.Router) { r.GET("/login", h.showLogin) r.POST("/login", h.handleLogin) r.POST("/logout", h.handleLogout) } func (h *AuthHandler) showLogin(c forge.Context) error { return c.Render(http.StatusOK, views.LoginPage()) }The Context interface embeds context.Context, so it can be passed directly to any function expecting a standard library context. It also provides built-in helpers for common tasks:
func (h *Handler) getUser(c forge.Context) error { // c satisfies context.Context — pass it to DB calls, HTTP clients, etc. user, err := h.repo.GetUser(c, c.UserID()) if err != nil { return err } return c.JSON(200, user) }Context carries everything you need for a request — logging, cookies, flash messages, domain info:
func (h *Handler) updateSettings(c forge.Context) error { c.LogInfo("updating settings", "user", c.UserID(), "domain", c.Domain()) // Flash messages for post-redirect-get c.SetFlash("success", "Settings saved!") return c.Redirect(http.StatusSeeOther, "/settings") } func (h *Handler) showSettings(c forge.Context) error { var msg string c.Flash("success", &msg) // reads and deletes flash return c.Render(http.StatusOK, views.Settings(msg)) }Generic helpers provide type-safe access to URL and query parameters:
func (h *Handler) listItems(c forge.Context) error { page := forge.QueryDefault[int](c, "page", 1) limit := forge.QueryDefault[int](c, "limit", 20) id := forge.Param[int64](c, "id") items, err := h.repo.ListItems(c, page, limit) if err != nil { return err } return c.JSON(http.StatusOK, items) }Supported types: ~string, ~int, ~int64, ~float64, ~bool.
Bind request data into structs with automatic sanitization and validation:
type CreateContact struct { Name string `form:"name" validate:"required;max:100"` Email string `form:"email" validate:"required;email"` Phone string `form:"phone" sanitize:"trim;numeric"` } func (h *Handler) createContact(c forge.Context) error { var req CreateContact if errs, err := c.Bind(&req); err != nil { return err } else if errs != nil { return c.Render(http.StatusUnprocessableEntity, views.Form(errs)) } // req is sanitized and validated return h.repo.CreateContact(c, req.Name, req.Email, req.Phone) }Also available: c.BindJSON() for API endpoints and c.BindQuery() for query parameters.
Enable server-side session management with automatic creation:
app := forge.New( forge.AppConfig{}, forge.WithSession(postgresStore, forge.WithSessionTTL(7 * 24 * time.Hour), forge.WithMaxSessionsPerUser(3), forge.WithSessionFingerprint( forge.FingerprintCookie, forge.FingerprintWarn, ), ), )Authenticate users with AuthenticateSession — it sets the user ID, rotates the session token, and marks the session as authenticated in one call:
func (h *Handler) login(c forge.Context) error { // ...validate credentials... if err := c.AuthenticateSession(user.ID); err != nil { return err } return c.Redirect(http.StatusSeeOther, "/dashboard") }Then use the built-in identity methods — no need to manually read session keys:
func (h *Handler) dashboard(c forge.Context) error { if !c.IsAuthenticated() { return c.Redirect(http.StatusSeeOther, "/login") } // c.UserID() returns the authenticated user's ID user, err := h.repo.GetUser(c, c.UserID()) if err != nil { return err } canEdit := c.IsCurrentUser(user.ID) return c.Render(http.StatusOK, views.Dashboard(user, canEdit)) }For custom session data, use SessionGet and SessionSet:
forge.SessionSet(c, "theme", "dark") theme, ok := forge.SessionGet[string](c, "theme")Session management:
c.DestroySession() // Logout current device c.DestroyOtherSessions() // Logout all other devices c.DestroyAllSessions(c.UserID()) // Logout everywhere sessions, _ := c.ListSessions(c.UserID()) // Show active sessionsConfigure role-based access control:
app := forge.New( forge.AppConfig{}, forge.WithRoles( forge.RolePermissions{ "admin": {"users.read", "users.write", "billing.manage"}, "member": {"users.read"}, }, func(c forge.Context) string { return forge.ContextValue[string](c, roleKey{}) }, ), )Check permissions in handlers:
func (h *Handler) deleteUser(c forge.Context) error { if !c.Can("users.write") { return forge.ErrForbidden("You do not have permission") } return h.repo.DeleteUser(c, forge.Param[string](c, "id")) } func (h *Handler) adminPanel(c forge.Context) error { c.LogInfo("admin access", "role", c.Role(), "user", c.UserID()) return c.Render(http.StatusOK, views.Admin()) }Enable background job processing with River:
app := forge.New( forge.AppConfig{}, forge.WithJobs(pgxPool, job.Config{Workers: 2}, job.WithTask(EmailTask{}), job.WithScheduledTask(CleanupTask{}), ), )Define tasks using structural typing:
type EmailTask struct{} func (EmailTask) Name() string { return "send_email" } func (EmailTask) Handle(ctx context.Context, p struct{ Email string }) error { // Send email... return nil }Enqueue jobs from handlers:
func (h *Handler) signup(c forge.Context) error { err := c.Enqueue("send_email", struct{ Email string }{Email: user.Email}, job.WithQueue("emails"), job.WithScheduledIn(1*time.Minute), ) if err != nil { return err } return c.Redirect(http.StatusSeeOther, "/signup/confirm") }Enable S3-compatible file storage:
s, err := storage.New(storage.Config{ Endpoint: "s3.amazonaws.com", AccessKey: os.Getenv("AWS_ACCESS_KEY_ID"), SecretKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), Bucket: "myapp-uploads", Region: "us-east-1", }) if err != nil { log.Fatal(err) } app := forge.New( forge.AppConfig{}, forge.WithStorage(s), )Upload, download, and manage files directly from handlers:
func (h *Handler) uploadAvatar(c forge.Context) error { info, err := c.Upload("avatar", storage.WithPrefix("avatars"), storage.WithTenant(c.UserID()), storage.WithValidation( storage.MaxSize(5*1024*1024), storage.ImageOnly(), ), ) if err != nil { return err } // Save info.Key to database, generate URLs later return c.JSON(http.StatusOK, map[string]string{"key": info.Key}) } func (h *Handler) getAvatarURL(c forge.Context) error { url, err := c.FileURL(avatarKey, storage.WithExpiry(1*time.Hour)) if err != nil { return err } return c.JSON(http.StatusOK, map[string]string{"url": url}) }Context automatically detects HTMX requests and renders accordingly:
func (h *Handler) contacts(c forge.Context) error { contacts, err := h.repo.ListContacts(c) if err != nil { return err } // HTMX request → renders just the partial // Regular request → renders the full page with layout return c.RenderPartial(http.StatusOK, views.ContactsPage(contacts), // full page views.ContactsList(contacts), // partial for HTMX ) }Check HTMX state: c.IsHTMX() returns true for HTMX-initiated requests. Redirects automatically use HX-Redirect headers when appropriate.
Stream events to clients using channels:
func (h *Handler) streamEvents(c forge.Context) error { ch := make(chan forge.SSEEvent) go func() { defer close(ch) for { select { case <-c.Done(): return case event := <-eventChan: ch <- forge.SSEString("message", event.Data) } } }() return c.SSE(ch) }Return errors from handlers using convenience constructors or the context helper:
func (h *Handler) getUser(c forge.Context) error { id := forge.Param[string](c, "id") user, err := h.repo.GetUser(c, id) if err == sql.ErrNoRows { return forge.ErrNotFound("User not found") } if err != nil { // c.Error() creates an HTTPError with the given status and message return c.Error(500, "Failed to fetch user", forge.WithError(err)) } return c.JSON(http.StatusOK, user) }Customize error handling globally:
app := forge.New( forge.AppConfig{}, forge.WithErrorHandler(func(c forge.Context, err error) error { if httpErr := forge.AsHTTPError(err); httpErr != nil { return c.JSON(httpErr.StatusCode(), httpErr) } return c.JSON(http.StatusInternalServerError, map[string]string{ "message": "Something went wrong", }) }), )Import from github.com/dmitrymomot/forge/middlewares:
- RequestID — Request tracking IDs
- Recover — Panic recovery
- I18n — Internationalization and localization
- JWT — Token-based authentication
- CSRF — Cross-site request forgery protection
- RateLimit — Request rate limiting
- AuditLog — Request and action logging
- CORS — Cross-origin resource sharing
- Auth — Authentication checks
- RBAC — Role-based access control
Available in github.com/dmitrymomot/forge/pkg:
binder— Request binding with validationcache— Caching utilitiesclientip— Client IP extractioncookie— Secure cookie managementdb— Database utilitiesdnsverify— DNS verification helpersfingerprint— Browser fingerprintinggeolocation— IP geolocationhostrouter— Multi-domain routinghtmx— HTMX helpersi18n— Internationalizationid— ID generation (ULID, ShortID)job— Background job processingjwt— JWT utilitieslogger— Structured loggingmailer— Email sendingoauth— OAuth 2.0 helpersqrcode— QR code generationrandomname— Random name generationratelimit— Rate limitingredis— Redis utilitiessanitizer— HTML sanitizationsecrets— Secrets managementslug— URL slug generationstorage— File storagetoken— Token generationtotp— TOTP/2FAuseragent— User agent parsingvalidator— Input validationwebhook— Webhook utilities
Load environment variables into structs:
type Config struct { DatabaseURL string `env:"DATABASE_URL,required"` Port string `env:"PORT" envDefault:":8080"` Debug bool `env:"DEBUG"` } var cfg Config if err := forge.LoadConfig(&cfg); err != nil { log.Fatal(err) }Compose multiple apps with host-based routing:
api := forge.New( forge.AppConfig{BaseDomain: "acme.com"}, forge.WithHandlers(handlers.NewAPIHandler()), ) website := forge.New( forge.AppConfig{BaseDomain: "acme.com"}, forge.WithHandlers(handlers.NewLandingHandler()), ) if err := forge.Run( forge.RunConfig{Address: ":8080"}, forge.WithDomain("api.acme.com", api), forge.WithDomain("*.acme.com", website), ); err != nil { log.Fatal(err) }just test # Tests with race detection + coverage just bench # Benchmarks with memory stats just lint # vet, golangci-lint, nilaway, betteralign, modernize just fmt # Format + organize imports just test-integration # Docker-based integration testsforge-skills is a Claude Code plugin that accelerates Forge development with three skills:
/forge-init <app-name>— Scaffold a complete project (config, Docker, env, task runner) with selectable subsystems (Postgres, Redis, sessions, jobs, storage, templ, HTMX, Tailwind, mailer, OAuth)/forge-build <feature>— Generate handlers, DB migrations + sqlc queries, background jobs, auth flows, email templates, storage integration, SSE endpoints, and templ views/templui <description>— Generate Go templ templates using the templui component library (41 components, Tailwind + HTMX-ready)
Install inside Claude Code (v1.0.33+):
/plugin marketplace add dmitrymomot/forge-skills /plugin install forge-skills@forge-skills Full API documentation is available via:
go doc -all github.com/dmitrymomot/forgeOr online at pkg.go.dev/github.com/dmitrymomot/forge
See CONTRIBUTING.md for guidelines.
- No reflection, no service containers, no magic
- Packages receive values via parameters, not context
- Public methods must not return unexported types
- Framework provides utility packages; business logic belongs in consumer repos
- All IDs generated using
pkg/id/package exclusively
Apache 2.0 — see LICENSE for details.