Build Your First App

From zero to running web application. Every step uses standard Go.

01 — Download

Get Congo

Download a prebuilt binary for your platform from the download page. Unpack it and put it on your PATH:

# macOS / Linux
tar -xzf congo-*.tar.gz
mv congo /usr/local/bin/
congo version

Requires Go 1.25+ to build projects. The binary itself has no dependencies.

02 — Scaffold

Create a Project

congo init myapp
cd myapp
congo dev

Open localhost:5000 in your browser — you'll see a welcome page with live reload. Edit any template and the browser updates automatically.

This creates a new directory with the full framework vendored inside:

myapp/
  internal/          Framework source (yours to read and modify)
    application/     HTTP server, routing, templates, middleware
    database/        ORM, auto-migration, SQLite/LibSQL engines
    frontend/        React islands, esbuild, HMR
    assistant/       AI chat, streaming, tool calling
    platform/        Cloud server management, Docker, SSH
  web/
    controllers/     Your request handlers
    models/          Your data models
    views/           Your HTML templates
      layouts/       Page layouts
      partials/      Reusable components
      static/        CSS, JS, images
    main.go          Entry point
  go.mod
  Dockerfile
  CLAUDE.md          AI context (generated by congo claude)

The framework lives in internal/. It's regular Go code. No hidden magic, no code generation, no reflection tricks. Open any file and read it.

03 — Controllers

Add a Controller

Controllers handle HTTP requests and expose methods to templates. Create web/controllers/todos.go:

package controllers

import (
    "net/http"
    "myapp/internal/application"
    "myapp/web/models"
)

func Todos() (string, *TodosController) {
    return "todos", &TodosController{}
}

type TodosController struct {
    application.BaseController
}

func (c *TodosController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    http.Handle("GET /todos", app.Serve("todos.html", nil))
    http.Handle("POST /todos", app.Method(c, "Create", nil))
}

// Value receiver creates a copy — each request gets its own state.
func (c TodosController) Handle(r *http.Request) application.Controller {
    c.Request = r
    return &c
}

// Public methods are callable from templates: {{todos.All}}
func (c *TodosController) All() []*models.Todo {
    items, _ := models.Todos.All()
    return items
}

func (c *TodosController) Create(w http.ResponseWriter, r *http.Request) {
    todo := &models.Todo{Title: r.FormValue("title")}
    models.Todos.Insert(todo)
    c.Redirect(w, r, "/todos")
}

Register it in web/main.go:

application.Serve(views,
    application.WithController(controllers.Home()),
    application.WithController(controllers.Todos()),
)

The factory function returns a name and controller. The name becomes the template namespace — todos.All calls the All() method.

Why value receiver on Handle()? Go copies the struct when you use a value receiver (c TodosController instead of *TodosController). Each HTTP request gets its own copy of the controller, so concurrent requests can't interfere with each other. All other methods use pointer receivers as normal.

04 — Models

Add a Model

Models are Go structs. The ORM creates tables, migrates schemas, and provides type-safe CRUD. Create web/models/todo.go:

package models

import "myapp/internal/database"

type Todo struct {
    database.Model
    Title string
    Done  bool
}

var Todos = database.Manage(DB, new(Todo))

That's it. The table is created on startup. Columns are added automatically when you add struct fields. database.Model provides ID, CreatedAt, and UpdatedAt.

// Insert — generates UUID, sets timestamps
id, err := models.Todos.Insert(&models.Todo{Title: "Ship it"})

// Get by ID
todo, err := models.Todos.Get(id)

// Search with SQL (PascalCase column names)
done, err := models.Todos.Search("WHERE Done = ?", true)

// Update — auto-updates UpdatedAt
todo.Done = true
err = models.Todos.Update(todo)

// Delete
err = models.Todos.Delete(todo)

IDs are always strings (UUIDs). Add indexes with database.WithUniqueIndex or database.WithIndex.

Why PascalCase SQL? Column names match Go struct fields exactly — Title in the struct becomes Title in SQL. No mapping layer, no tags, no surprises. WHERE Done = ? reads the same as todo.Done.

05 — Views

Write a View

Views are standard Go html/template files with HTMX attributes. Create web/views/todos.html:

{{template "main.html" .}}

{{define "content"}}
<div class="container mx-auto px-8 py-16 max-w-2xl">
    <h1 class="text-3xl font-bold mb-8">Todos</h1>

    <form hx-post="/todos" hx-target="body" class="flex gap-2 mb-8">
        <input name="title" class="input input-bordered flex-1"
               placeholder="What needs doing?" required />
        <button class="btn btn-primary">Add</button>
    </form>

    {{range todos.All}}
    <div class="flex items-center gap-3 py-2">
        <span>{{.Title}}</span>
    </div>
    {{end}}
</div>
{{end}}

Controller methods like todos.All are called directly in templates. HTMX handles form submissions and page updates without writing JavaScript.

Why filename only? Templates reference layouts and partials by filename — {{template "main.html" .}}, not by path. All templates are in a flat namespace, so you never need to remember directory structures. Move files around without updating references.

06 — Testing

Write Tests

Congo uses an in-memory SQLite database when no DB_PATH or DB_URL is set — which means tests get a fresh database automatically. Test models with standard Go tests:

package models_test

import (
    "testing"
    "myapp/web/models"
)

func TestTodoInsert(t *testing.T) {
    id, err := models.Todos.Insert(&models.Todo{Title: "Ship it"})
    if err != nil {
        t.Fatal(err)
    }

    todo, err := models.Todos.Get(id)
    if err != nil {
        t.Fatal(err)
    }
    if todo.Title != "Ship it" {
        t.Errorf("got %q, want %q", todo.Title, "Ship it")
    }
}
go test ./web/models/...

External dependencies have mock providers built in — assistant/providers/mock for AI features and platform/providers/mock for infrastructure. No external services needed to run your test suite.

07 — AI

AI-Assisted Development

congo claude

This launches Claude Code with a complete framework reference injected into the session. The AI knows the controller pattern, the model API, the template conventions, and the HTMX patterns. It writes code that fits your project because the framework taught it how.

08 — Deploy

Build and Deploy

# Build a production binary
congo build

# Deploy to your server
congo launch

congo build compiles your app into a single binary. congo launch builds a Docker image, ships it to your server, and starts it with health checks and automatic rollback. Your infrastructure is defined in infra.json — servers, volumes, services, all in one file.

09 — Fork the Framework

Generational Development

Every Congo binary carries the complete source tree inside it. Run congo source to extract a buildable copy — the CLI, the framework packages, the scaffold templates, everything.

congo source ./my-framework
cd my-framework
go build -o my-cli ./cmd

This is how Congo is meant to evolve. Take the source, modify it, ship your own version. Your CLI carries your changes inside it. When someone runs my-cli source, they get your fork — and can fork it again. Each generation inherits and builds on the last.

Software that reproduces itself. Every binary is a seed for the next version. No central repository. No permission needed. Fork it, improve it, pass it on.

Packages

Framework Packages

Congo includes five packages. Use what you need, exclude what you don't with flags like --no-frontend or --no-database.

Every package is linked to its source. Read the implementation, understand the abstractions, modify them if you want.

Stay Updated

Get notified about new releases and updates.