Airway is a full-stack Go framework inspired by Ruby on Rails. It builds server-rendered web apps, JSON APIs, static showcase sites, and native desktop apps from the same application code. It runs against PostgreSQL, MySQL 8 and SQLite — the database driver is inferred from the DSN at runtime.
- 中文文档
- Admin panel guide / Admin 后台指南(中文)
- Static showcase sites / 静态展示站点(中文)
- Static export guide / 静态导出指南(中文)
- CLI scaffolding guide / CLI 脚手架指南
- Frontend guide / 前端指南(中文)
- OpenAPI guide / OpenAPI 指南(中文)
- Desktop guide / 桌面应用指南(中文)
- Plugin guide / Plugin 扩展机制(中文)
- Storage guide / 文件存储指南
- templ views guide / 视图模板指南
- SQL Builder DSL 指南(中文)
- Generics-based repository (
lib/repo): typedFindBy[User],CreateFrom[User], preload/eager-loading, joins, transaction-bound helpers. - Dialect-aware SQL builder (
lib/sql+pg/mysql/sqlitedialects); conditions likesql.Eq,sql.AllOf,sql.Gt; nativeFOR UPDATE SKIP LOCKEDsupport (sql.ForUpdateSkipLocked). - Schema-driven migrations: generate and apply migrations with the CLI; SQLite schema changes are handled by table rebuild.
- Unified file storage (
lib/storage): local directory, Amazon S3, Cloudflare R2 or Tencent COS — selected purely by configuration, exposed via an HTTP upload/download API. - Gin web server + WebSocket pub/sub.
- HTML views with templ: pages as
.templtemplates underapp/views/, rendered from actions vialib/render.HTML. - Frontend without Node: npm dependencies managed by the CLI
(
js:add/js:install,js.pkg.jsonlock), bundled with an embedded esbuild (js:build), dev-time in-memory rebuilds with livereload, and interactive Preact islands built on the bundled airway-ui component library — all committed and embedded into the single Go binary. - OpenAPI 3.2 documentation generated from the running router —
operationIds derived from handlers, enriched per module in code, served
live at
/openapi.json. - Admin panel generator: a TOML table spec produces a complete admin backend — authentication, roles, an audit trail, CSV export and server-side lists.
- Desktop apps:
airway desktop:initexports the project as a Wails v3 desktop target; the same web stack runs in a native WebView window on macOS, Windows and Linux. - Static showcase sites:
airway ssg:newscaffolds a static site whose pages are Go code and whose looks come from swappable theme modules (theme:new/theme:install);airway ssg:buildexports plain HTML for any static host. - Static export for CDN:
airway static:buildexports app pages (registered inexport.go) as plain HTML plus the frontend bundle — templ-rendered content with Preact islands intact, deployable to any CDN (see docs/static-export.md). - Plugins: WordPress-style feature modules shipped as independent Go
modules — install with
go get, enable with one blank import inplugins.go(see docs/plugin.md). - Scaffolding CLI (
airway generate ...,db:migrate, ...). - Repo REPL with typed scan and Go-expression evaluation.
- Optional sub-path prefix (
URL_PREFIX) for deploying behind a reverse proxy, e.g.http://host:1900/airway/....
Requires Go 1.27.1 or later. No Node.js, no CGO.
go install github.com/daqing/airway@latest
airway new myapp # or: airway new github.com/me/myapp (directory = last path segment)
# or: airway new /path/to/myapp (path; module = last path segment)
# relative paths work the same: airway new sites/myapp
cd myappairway new generates a fresh project skeleton from the framework's app/
scaffold, seeds .env from .env.example, runs go mod tidy, installs the
frontend dependencies (airway js:install), and prints the
follow-up steps. To hack on the framework itself instead, clone the repository:
git clone https://github.com/daqing/airway.git
cd airway
cp .env.example .envOpen .env and set at least the database DSN and listen address:
DSN="sqlite://./tmp/airway.db" # or postgres://..., mysql://...
LISTEN=":1900" # host:port to bind; ":1900" is every interfaceEnvironment values may use the short name (DSN, LISTEN, REDIS, URL_PREFIX)
or the AIRWAY_-prefixed form (AIRWAY_DSN, AIRWAY_LISTEN, ...). When both are
set, the AIRWAY_ form wins. See Configuration.
go run . server # or: airway server (requires AIRWAY_ENV, e.g. AIRWAY_ENV=local, and a configured DSN)Or start the local dev server with live reload:
just devThe app listens on http://127.0.0.1:1900 (GET / serves an HTML page
rendered with templ, GET /health returns UP).
All configuration is via environment variables (see .env.example). Values in
.env are fallbacks: the process environment always wins (e.g.
LISTEN=0.0.0.0:1988 airway server overrides a LISTEN in .env). Each value
accepts a short name or its AIRWAY_ alias, with the alias taking precedence.
| Variable | Description |
|---|---|
DSN / AIRWAY_DSN |
Database URL. Driver is inferred from the scheme (see below). |
LISTEN / AIRWAY_LISTEN |
Address the server binds, host:port (e.g. 0.0.0.0:1905, :1905). Default :1900. |
REDIS / AIRWAY_REDIS |
Optional Redis URL for cache/queue. |
URL_PREFIX / AIRWAY_URL_PREFIX |
Optional public sub-path prefix, e.g. /airway. Empty serves at the root. |
AIRWAY_JS_REGISTRY |
npm registry for js:add / js:install (default https://registry.npmjs.org; set a mirror like https://registry.npmmirror.com if needed). |
AIRWAY_ENV |
local loads .env and uses Gin debug mode; anything else runs release mode. |
STORAGE_DRIVER |
local (default), s3, r2 or cos. |
STORAGE_ROOT |
Local storage root (default ./data/storage). |
STORAGE_* |
Cloud settings: STORAGE_REGION, STORAGE_ENDPOINT, STORAGE_BUCKET, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY, STORAGE_PUBLIC_URL (optional CDN base). |
TZ |
Server timezone, e.g. Asia/Shanghai. |
The same code switches databases by changing only DSN:
# PostgreSQL
DSN="postgres://daqing:passwd@127.0.0.1:5432/airway"
# SQLite (file or in-memory) — pure-Go driver, no CGO required
DSN="sqlite://./tmp/airway.db"
DSN="sqlite://:memory:"
# MySQL 8 (URL or native driver format)
DSN="mysql://root:passwd@127.0.0.1:3306/airway?charset=utf8mb4"
DSN="root:passwd@tcp(127.0.0.1:3306)/airway?charset=utf8mb4&parseTime=true"Notes:
- Basic CRUD flows are portable across PostgreSQL, MySQL 8 and SQLite.
- Some advanced SQL Builder helpers (ARRAY, JSONB, a few lateral/window expressions) are still PostgreSQL-oriented.
- SQLite support uses the pure-Go
modernc.org/sqlitedriver.
Registered in config/routes.go:
| Method | Path | Description |
|---|---|---|
| GET | / |
Home page (HTML, rendered from a templ view). |
| GET | /ui |
airway-ui component showcase (interactive island). |
| GET | /openapi.json |
The live OpenAPI 3.2 document (see API documentation). |
| GET | /health |
Health check. |
| GET | /ws |
WebSocket connection. |
| POST | /ws/publish |
Publish a message to connected clients (form field message). |
| POST | /api/v1/storage |
Upload a file (multipart file, optional dir). |
| GET | /api/v1/storage/*key |
Download a file. |
| DELETE | /api/v1/storage/*key |
Delete a file. |
| GET | /api/v1/ui-demo/items |
Demo data for the TanStack island on /ui. |
When URL_PREFIX is set, the public routes — home page, WebSocket, and API —
are served only under that prefix, not at the root. The health check also stays
reachable at the bare root, so load-balancer probes can hit /health without
the prefix. For example with URL_PREFIX="/airway":
curl http://127.0.0.1:1900/airway/health
curl http://127.0.0.1:1900/health # still answers (probe)
curl -F "file=@report.pdf" http://127.0.0.1:1900/airway/api/v1/storageClients — including WebSocket connections — must include the prefix
(ws://host:1900/airway/ws). Local storage URLs returned by the API are
prefixed accordingly; cloud/CDN URLs are untouched.
Pages are templ templates under app/views/, one
folder per API module — the folder name drops the _api suffix, so home_api
renders app/views/home/index.templ. Each folder is its own package; a shared
document shell lives in app/views/layouts/base.templ. Actions serve a
component with the lib/render HTML helper:
// app/api/home_api/index_action.go
render.HTML(c, home.Index())After editing any .templ file, regenerate the Go code and keep it committed:
go generate ./... # or: just generate, or: airway templates:compileThe generated *_templ.go files are committed, so building and testing never
require the templ CLI.
Pages stay server-rendered; interactive regions are islands — Preact TSX
components mounted on data-island nodes with server-provided props:
// in a .templ view (see app/views/home for a live example)
@assets.Island("counter", map[string]any{"start": 3})The component lives at app/assets/js/islands/counter.tsx (default-export
it; the file path is the island name) and is bundled automatically — no
manual registration. In local development the bundle rebuilds in memory and
the browser reloads on change; in production it is embedded in the binary
behind a cache-busted URL. Pages render completely without JavaScript: the
mount point just stays empty.
airway-ui is the bundled component library islands build on: buttons,
inputs, forms (react-hook-form), tables (TanStack Table), modals, toasts,
tabs and a fetch layer aligned with lib/render's JSON envelope — visual
layer self-made, logic layers from the preact/compat ecosystem. See it all
live at /ui on a running server.
Status: implemented end to end — see the frontend guide.
The pipeline ships in the CLI (js:add/js:install/js:build,
generate island/scaffold), the project template, and this repository
itself (the homepage counter and the /ui component showcase are
islands).
Frontend code lives in the same repository as the Go code, gets a component-based workflow comparable to a modern UI framework, and does not introduce a Node.js toolchain:
- templ renders the skeleton — page structure, SEO, first paint.
- Interactive regions are islands — Preact TSX components under
app/assets/js/, mounted on elements marked withdata-island; initial data is serialized next to the mount point. - esbuild embedded as a Go library — the CLI links
github.com/evanw/esbuild/pkg/apidirectly:airway js:buildcompiles TS/TSX, the dev server serves rebuilt bundles from memory, and production bundles are embedded into the single Go binary viago:embed. - A home-grown
airway-uicomponent library on apreact/compatbase, so logic-heavy React-ecosystem libraries (TanStack Table/Query/Form, React Hook Form) stay usable while the visual layer stays self-made.
Rejected alternatives:
- htmx + Alpine.js (HTML over the wire) — fine for progressive enhancement, but it offers no component-based reactive programming model; interactivity caps out well short of a real component library.
- A Node frontend sub-project (Vue, React/Next.js, Svelte, ...) embedded
via
go:embed— drags a full Node toolchain into the repository; at that point a real frontend/backend split is the more honest architecture. - LiveView-style server-driven UI — little practical value for a Go framework; when an application genuinely needs heavy frontend engineering, splitting the frontend out is the right answer.
Escape hatch: applications that outgrow islands (complex SPAs, rich editors) should split the frontend into its own project (Vue, React, Svelte, ...) and consume Airway purely as a JSON API.
Every API route served by the binary is documented as an OpenAPI 3.2
document — no annotations required. airway openapi:generate scans the
router, derives operationIds from the handler names, and writes
./openapi.json (a build artifact, git-ignored — regenerate it whenever
routes change). The same document is served live at
/openapi.json.
Modules enrich their documentation in code with an openapi.go file
declaring operations via lib/openapi (openapi.Get(...) etc.), including
request/response schemas inferred from Go types; document-level metadata
lives in app/api/openapi_api/doc.go. See the
OpenAPI guide.
airway admin:generate reads a TOML table spec (config/admin.toml) and
generates a complete, production-ready admin backend as real, user-owned Go
code: cookie-session authentication, roles, an audit trail, CSV export, and
server-side lists — with type-aware forms and filters for every declared
field (datetime, enum, references, attachment included).
airway admin:generate # or: admin:generate --force=table1,table2
airway admin:root admin 's3cret' # create the administrator account
airway admin:member editor 's3cret' # non-admin panel accounts (--role=editor|viewer)
airway server # sign in at /admin/loginSee the admin panel guide (中文).
airway desktop:init exports the project as a Wails v3 desktop target in
./desktop: the same web stack (templ views, islands, JSON APIs, WebSocket)
runs on a local loopback port inside the desktop process, and a native
WebView window loads it — server-rendered pages, cookie sessions, redirects
and WebSockets behave exactly as on the web, with no application code
changes. SQL migrations ship embedded and apply automatically on launch;
packaging covers macOS (.app), Windows (NSIS) and Linux (deb/rpm/AppImage).
airway desktop:init # generate ./desktop; re-run to re-sync migrations/pluginsSee the desktop guide (中文) and the research record in WAILS.md.
Airway doubles as a static site generator for showcase websites — company
homepages, product landings, portfolios. airway ssg:new scaffolds a site
project whose pages are declared in Go (ssg.go) against a swappable
theme module; airway ssg:build exports a plain HTML directory for any
static host, and airway ssg:serve previews it locally. Themes are
ordinary Go modules (templ components + embedded assets): scaffold one with
airway theme:new, and install one into a site with
airway theme:install. The framework bundles a corporate reference theme.
airway ssg:new mysite # then: airway ssg:build / airway ssg:serveSee SSG.md (中文) for the design record and docs/ssg.md (中文) for the usage guide.
The Airway CLI is a single airway binary (install with
go install github.com/daqing/airway@latest). Inside a project it detects
the host application and transparently re-runs every project-scoped command
through go run . (stderr shows a proxying to project binary notice), so
plugins, REPL models and Go-code migrations always come from the project's
own binary. Commands auto-load .env from the project root.
airway new <module-path | /path> # scaffold a new project skeleton
airway server # start the HTTP server
airway generate api admin # new API namespace under app/api/
airway generate action admin show # new action in an existing API module
airway generate model post # new model in app/models/
airway generate service post title:string # CRUD service in app/services/
airway generate island chart # interactive island component
airway generate scaffold post title:string # full CRUD: model+migration+API+page+island
airway generate migration create_posts # new .up.sql/.down.sql pair in db/migrate/
airway repl # interactive repo REPL (proxied to go run . in projects)
airway version # print version (also -v, --version)airway db:create # create the database
airway db:drop # drop the database
airway db:migrate [version] # apply migrations
airway db:rollback [step] # roll back migrations
airway db:status # migration status
airway schema:dump # write db/schema.json
airway schema:show # print db/schema.jsonairway js:add <pkg>[@version] # add a frontend npm dependency (no Node required)
airway js:install # install js.pkg.json deps into app/assets/js/vendor/
airway js:build # bundle app/assets/js into app/assets/dist (esbuild)
airway templates:compile # regenerate the templ views (shorthand for `go generate ./...`)airway openapi:generate [--out path] # write the OpenAPI 3.2 document (default ./openapi.json)airway admin:generate [config/admin.toml] # generate the admin backend from a TOML table spec
airway admin:root <username> <password> # create the administrator account (role admin)
airway admin:member <username> <password> [--role=editor|viewer]
# create a non-admin panel accountairway desktop:init [--force] # generate the Wails v3 desktop target in ./desktopairway ssg:new [--local[=path]] <name> # scaffold a static showcase site project
airway ssg:build [--out dist] # export the site defined in ssg.go as static HTML
airway ssg:serve [--addr 127.0.0.1:3000] # preview the site with a local server
airway theme:new [--local[=path]] <name> # scaffold a new site theme module
airway theme:install <module | /path> # install a site theme into the host projectairway static:build [--out dist] # export the pages registered in export.go + the frontend bundle as static HTML
airway static:serve [--addr 127.0.0.1:3000] # preview the static pages with a local serverairway plugin:new <module-path | /path> # scaffold a new plugin module
airway plugin:list # registered plugins and mount paths
airway plugin:install <module> # enable a plugin + install its SQL migrations and deps/
airway plugin:lint # check the current plugin project for legacy layout issuesairway upload [key] /path/to/file # upload via the configured storageDatabase commands read DSN/AIRWAY_DSN. See the full
CLI guide.
A model is a struct with db tags and a TableName() method. Associations are
declared via Relations().
import "github.com/daqing/airway/lib/repo"
type User struct {
ID int64 `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
Profile *Profile // belongs_to
Posts []*Post // has_many
}
func (User) TableName() string { return "users" }
func (User) Relations() map[string]repo.Relation {
return map[string]repo.Relation{
"Profile": repo.HasOne(Profile{}, "UserID"),
"Posts": repo.HasMany(Post{}, "UserID"),
}
}
type Post struct {
ID int64 `db:"id"`
UserID int64 `db:"user_id"`
Title string `db:"title"`
Author *User // belongs_to
}
func (Post) TableName() string { return "posts" }
func (Post) Relations() map[string]repo.Relation {
return map[string]repo.Relation{
"Author": repo.NewBelongsTo(User{}, "UserID"),
}
}All helpers use the DB configured at boot (repo.SetupDB) and take the model
type as a type parameter:
// Create
user, err := repo.CreateFrom[User](sql.H{"name": "John", "email": "john@example.com"})
// Read
user, err := repo.FindByID[User](1)
user, err := repo.FindOneBy[User](sql.H{"email": "john@example.com"})
users, err := repo.FindBy[User](sql.H{"active": true})
users, err := repo.FindAll[User]()
// Update
err := repo.UpdateByID[User](1, sql.H{"name": "Jane"})
err := repo.UpdateWhere[User](sql.H{"status": "inactive"}, sql.Eq("last_login_at", nil))
// Delete
err := repo.DeleteByID[User](1)
err := repo.DeleteWhere[User](sql.H{"status": "banned"})
// Exists / Count
ok, err := repo.ExistsWhere[User](sql.H{"email": "john@example.com"})
n, err := repo.CountWhere[User](sql.H{"active": true})
n, err := repo.CountEvery[User]()repo.WithTx runs a callback on a single transaction-bound connection. The
callback receives a *repo.Tx whose helper methods execute on that
transaction; the generic helpers take tx.Executor() via the *With
variants:
users := sql.TableOf("users")
err := repo.WithTx(db, func(tx *repo.Tx) error {
if _, err := repo.InsertWith[User](tx.Executor(), sql.Insert(sql.H{"name": "John"}).IntoTable(users)); err != nil {
return err
}
n, err := tx.Count(sql.SelectColumns("count(*)").FromTable(users))
if err != nil {
return err
}
return nil // commit; a non-nil return rolls back
})Use tx.Raw() to drop down to *sql.Tx for hand-written SQL. There is no
public Commit/Rollback — the outcome follows the callback's return value,
and repo.WithTxContext accepts a context. JoinQuery/Preloader remain
pool-only and cannot run inside a transaction.
Optimistic locking recipe: UpdateAffected with a version check, so exactly
one concurrent writer wins:
affected, err := tx.UpdateAffected(
sql.UpdateTable(users).
Set(sql.H{"name": "Jane", "version": sql.Expr("version + 1")}).
Where(sql.AllOf(sql.Eq("id", 1), sql.Eq("version", currentVersion))),
)
if err != nil {
return err
}
if affected == 0 {
return errors.New("stale record")
}PostgreSQL is the primary target for transactions. The MySQL insert path issues a second lookup statement on the same connection, which is best-effort inside a transaction.
Preload replaces N+1 loops with a couple of queries:
users, _ := repo.FindBy[User](sql.H{})
err := repo.Preload("Profile", "Posts").Exec(&users)
// Nested and conditional
err := repo.Preload("Posts").ThenPreload("Comments").Exec(&users)
err := repo.PreloadCond("Posts", sql.AllOf(
sql.Eq("published", true),
sql.Gte("created_at", "2024-01-01"),
)).Exec(&users)results, err := repo.Join(User{}).LeftJoins("Profile").Find()
results, err := repo.Join(User{}).
Joins("Posts", sql.Gt("posts.views", 100)).
Where(sql.Eq("users.active", true)).
OrderBy("users.name ASC").
Page(1, 20).
Find()
count, err := repo.Join(User{}).Joins("Posts").Count()
var users []*User
err := repo.Join(User{}).LeftJoins("Profile").FindInto(&users)| Rails ActiveRecord | Airway |
|---|---|
User.find(id) |
repo.FindByID[User](id) |
User.find_by(email: e) |
repo.FindOneBy[User](sql.H{"email": e}) |
User.where(active: true) |
repo.FindBy[User](sql.H{"active": true}) |
User.all |
repo.FindAll[User]() |
User.create(attrs) |
repo.CreateFrom[User](attrs) |
User.update(id, attrs) |
repo.UpdateByID[User](id, attrs) |
User.delete(id) |
repo.DeleteByID[User](id) |
User.joins(:profile) |
repo.Join(User{}).Joins("Profile") |
User.includes(:posts) |
repo.Preload("Posts").Exec(&users) |
User.count |
repo.CountEvery[User]() |
User.where(active: true).count |
repo.CountWhere[User](sql.H{"active": true}) |
Exercise lib/repo directly against the configured database:
go run . repl # uses the configured DSN
go run . repl --driver sqlite --dsn ./tmp/airway.dbThe REPL only sees the models compiled into the binary it runs in (registered
via lib/replreg). Inside a project, airway repl proxies to go run . repl
automatically, so your project's models show up; outside a project only the
framework's built-in models are visible.
Commands: help, driver, tables, exit. Type a Go expression to evaluate
it — builders print the compiled SQL, repo.* calls run against the database:
repo.FindOne("posts", sql.Eq("id", 1))
repo.Find[models.Post](pg.Select("id").Where(sql.Eq("id", 1)))
repo.Insert[models.Post](pg.H{"id": 1234, "title": "hello"})
repo.Update("posts", pg.H{"published": false}, sql.Eq("id", 1))
repo.Delete("posts", sql.Eq("id", 1))
pg.Select("*").From("posts").Where(sql.Eq("id", 1))
Available namespaces: repo, sql, pg, mysql, sqlite, models.
repo.Find/FindOne/Count/Exists accept a built statement or a
table + condition; typed calls support anonymous structs and app models.
Full-table updates/deletes require an explicit true as the last argument.
lib/storage is a unified layer over a local directory or a cloud backend
(S3 / R2 / COS). Configure the backend with STORAGE_DRIVER:
# Local
STORAGE_DRIVER="local"
STORAGE_ROOT="./data/storage"
# Cloud (S3-compatible): choose s3 / r2 / cos
STORAGE_DRIVER="s3"
STORAGE_REGION="us-east-1"
STORAGE_BUCKET="my-bucket"
STORAGE_ACCESS_KEY="..."
STORAGE_SECRET_KEY="..."
# STORAGE_PUBLIC_URL="https://cdn.example.com" # optional CDN baseUse it in code via the current backend:
store := storage.Current() // installed at boot by storage.Setup
err := store.Put(ctx, "docs/report.pdf", storage.Object{
Reader: file,
Size: size,
ContentType: "application/pdf",
})
rc, err := store.Get(ctx, "docs/report.pdf") // close when done
ok, err := store.Exists(ctx, "docs/report.pdf")
url, err := store.URL(ctx, "docs/report.pdf", 24*time.Hour)
err := store.Delete(ctx, "docs/report.pdf")URL() returns the app's own download path for the local backend and a CDN or
presigned URL for cloud backends. The app exposes a REST API over the same
layer:
# Upload (multipart field "file", optional "dir")
curl -F "file=@report.pdf" -F "dir=docs" http://127.0.0.1:1900/api/v1/storage
# => {"key":"docs/202609/ab12cd....pdf","url":"/api/v1/storage/docs/202609/ab12cd....pdf","size":12345}
# Download / delete
curl -O http://127.0.0.1:1900/api/v1/storage/docs/202609/ab12cd....pdf
curl -X DELETE http://127.0.0.1:1900/api/v1/storage/docs/202609/ab12cd....pdfSee the full storage guide.
The binary is self-contained. Build a pure-Go image (no CGO toolchain) with:
just docker # or: docker build -t airway .
docker run -p 1900:1900 -e AIRWAY_ENV=production -e DSN="sqlite:///app/tmp/airway.db" airwayThe Dockerfile pulls base images from a daocloud.io mirror and points the Go module proxy at goproxy.cn, so builds work on networks where the default endpoints are slow or blocked; strip the mirrors if you don't need them.
Run migrations before/after deploy:
./airway db:migrateSee docs/docker-compose.yml.example for a
compose example. To serve the app under a path prefix behind a reverse proxy,
set URL_PREFIX (e.g. /airway) — see HTTP endpoints.