Tutorial, part 1: bootstrap, routing, JSON views
This tutorial builds one small application — a bulletin board called board — from an empty directory to a running app with persistence, migrations, and an admin. This part covers scaffolding, routing, and JSON views. By the end you'll have a running go run . serving a real route.
Scaffold the project
tango newproject board
cd board
This creates a new Go module (board) pre-wired for SQLite and the admin app: a main.go, a project package whose Config lists the installed apps and the middleware, and a shell program that tango shell runs (part 3 uses it). Admin accounts live in the database rather than a generated file — you'll create one with tango admin create in part 3, once the admin's own tables exist. The generated main.go stays small: it opens the database, builds the config with project.Config, calls tango.DispatchFlags, then calls tango.ServeContext under a context that Ctrl-C or SIGTERM cancels, so the server stops gracefully. Confirm it runs:
go run . -check
# check passed
Add an app
tanGO groups related models and routes into apps — plain Go packages implementing a two-method interface:
type App interface {
Name() string
Register(*Registry) error
}
Scaffold one:
tango newapp posts
This writes apps/posts/app.go with a stub Name()/Register(), and prints the line to add to project/project.go. It does not edit project/project.go for you — wiring a new app in is always one line you write yourself, so nothing about your project's composition is hidden:
// project/project.go (as of part 1)
config := tango.LoadConfigFromEnv(tango.WithPortFromEnv())
config.InstalledApps = []tango.App{
posts.App{},
// keep the generated admin.New(...) entry after your apps
}
The generated project/project.go reads the listen address from the environment: TANGO_ADDR, else the PORT a hosting platform sets, else :8000. It also sets up some middleware, which part 9 explains.
(add the import for "board/apps/posts" alongside it.)
Named routes
Inside apps/posts/app.go, Register declares routes via Include, a namespace prefix, and a list of Path declarations:
// apps/posts/app.go (as of part 1)
package posts
import "github.com/angvp/tango"
type App struct{}
func (App) Name() string { return "posts" }
func (App) Register(registry *tango.Registry) error {
return registry.Routes().Include("/posts/", tango.URLs{
tango.Path("GET", "/", listPosts, tango.Name("list")),
tango.Path("GET", "/{id}/", getPost, tango.Name("detail")),
})
}
Route names are namespace-qualified: the routes above are known internally as posts:list and posts:detail. Reverse lookup (turning a route name back into a URL) uses this qualified name — see the routing guide for the full API.
JSON views
A View is func(*tango.Context) error. Context gives you Param (path parameters), Query (query string values), Bind (decode a request body), and JSON (write a JSON response). Views live next to the app in apps/posts/views.go, which starts with package posts and imports github.com/angvp/tango:
// apps/posts/views.go (as of part 1)
func listPosts(ctx *tango.Context) error {
return ctx.JSON(200, map[string]string{"posts": "none yet"})
}
func getPost(ctx *tango.Context) error {
id := ctx.Param("id")
return ctx.JSON(200, map[string]string{"id": id})
}
A view returning a non-nil error maps to a fixed 500 JSON response; the error is logged server-side but never exposed to the client, so it's safe to return errors from database calls directly (added in part 2).
Run it
go run . -check # validates registration and route compilation, doesn't start the server
go run . # starts serving on :8000
curl http://localhost:8000/posts/
curl http://localhost:8000/posts/1/
You now have a running tanGO app with two named, working JSON routes. Part 2 adds a real model and persists it through generated migrations.