# Anetos docs > The documentation of Anetos, a batteries-included web framework for Go. This is the documentation of Anetos main (unreleased). Every page, as Markdown, in the sidebar's order; the index is https://docs.anetos.dev/llms.txt. # Getting started Source: https://docs.anetos.dev/getting-started/ From nothing to an app with pages, accounts and tests, in the order you'd do it. About fifteen minutes; the tutorial then builds a whole app in an hour. **Installation** 1. [Install Anetos](https://docs.anetos.dev/getting-started/install/index.md): Go and the `anetos` tool. 2. [Set up your editor](https://docs.anetos.dev/getting-started/editor/index.md) for Go and templ. 3. [Choose a database](https://docs.anetos.dev/getting-started/databases/index.md): SQLite to start, PostgreSQL or MySQL when you like. 4. [Build with a coding agent](https://docs.anetos.dev/getting-started/coding-agents/index.md) (optional): Claude Code, Codex, Cursor, Gemini CLI or GitHub Copilot. **Your first app** 5. [Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md) and run it with live reload. 6. [Project structure](https://docs.anetos.dev/getting-started/project-structure/index.md): what each folder holds. 7. [Add pages for a model](https://docs.anetos.dev/getting-started/crud/index.md) with `make:crud`. 8. [Add accounts](https://docs.anetos.dev/getting-started/add-accounts/index.md) with `make:auth`. 9. [Test and build](https://docs.anetos.dev/getting-started/test-and-build/index.md) for production. **[Tutorial: build an issue tracker](https://docs.anetos.dev/getting-started/tutorial/index.md)**: accounts, pages by hand, htmx, search, events, a queue and email, step by step. **An API instead**: a JSON API for a mobile app or a front end built separately. 1. [Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md#an-api-instead) with `--stack=api`. 2. [Add accounts to an API](https://docs.anetos.dev/guides/api-accounts/index.md): log in with tokens. 3. [Add endpoints for a model](https://docs.anetos.dev/getting-started/crud/index.md#in-an-api-project) with `make:crud`. 4. **[Tutorial: build an API](https://docs.anetos.dev/getting-started/build-an-api/index.md)**: bookmarks that are each user's, tokens for scripts, tests and the API's OpenAPI description, in half an hour. Then the [guides](https://docs.anetos.dev/guides/index.md) take one feature at a time, and the [concepts](https://docs.anetos.dev/concepts/index.md) explain how Anetos works. # Installation Source: https://docs.anetos.dev/getting-started/installation/ - [Install Anetos](https://docs.anetos.dev/getting-started/install/index.md): Anetos needs Go and its command-line tool, `anetos`, which creates projects and writes code. - [Set up your editor](https://docs.anetos.dev/getting-started/editor/index.md): An Anetos app's code is Go, and its pages are [templ](https://templ.guide) files (`.templ`): HTML with Go expressions, compiled to Go. Your editor needs to know both. - [Choose a database](https://docs.anetos.dev/getting-started/databases/index.md): Anetos apps run on SQLite, PostgreSQL, MySQL and MariaDB. Pick one when you create the project; you can switch later. - [Build with a coding agent](https://docs.anetos.dev/getting-started/coding-agents/index.md): Coding agents such as Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot can build an Anetos app with you. Every project `anetos new` makes is ready for them: it has instructions they read before they start, guides for the common tasks, the documentation at the project's version a command away, and one command that tells them whether a change works. # Install Anetos Source: https://docs.anetos.dev/getting-started/install/ Anetos needs Go and its command-line tool, `anetos`, which creates projects and writes code. ## Go Install Go 1.26 or later from [go.dev/dl](https://go.dev/dl/), then check: ```sh go version # go version go1.26.x … ``` Nothing else is required: the default database, SQLite, is built into the app (in pure Go, so no C compiler either), and the views' compiler, templ, comes with each project. ## The `anetos` tool ```sh go install anetos.dev/anetos/cli/cmd/anetos@v0.5.0-rc.1 anetos version ``` `go install` puts it in `$(go env GOPATH)/bin` (usually `~/go/bin`): add that folder to your `PATH` if `anetos` isn't found. v0.5.0-rc.1 is the release candidate of v0.5.0, the first public release. Until v0.5.0 is out, name it as above: `@latest` still finds an earlier version that can't be installed. You use this copy once per project, for `anetos new`. Each project then pins its own version of the tool in `go.mod`, and you run it there as `go tool anetos`, so everyone working on the project uses the same one. ## Upgrading `go install anetos.dev/anetos/cli/cmd/anetos@latest` again updates the tool for new projects. A project upgrades its own copy with Anetos: see [Upgrading](https://docs.anetos.dev/upgrade/index.md). Next: [Set up your editor](https://docs.anetos.dev/getting-started/editor/index.md). # Set up your editor Source: https://docs.anetos.dev/getting-started/editor/ An Anetos app's code is Go, and its pages are [templ](https://templ.guide) files (`.templ`): HTML with Go expressions, compiled to Go. Your editor needs to know both. ## Go Install your editor's Go support, which runs `gopls`, Go's language server: the Go extension in VS Code, the Go plugin in other JetBrains IDEs (built into GoLand), `nvim-lspconfig`'s `gopls` in Neovim. ## templ Install templ's extension: "templ-vscode" in VS Code, the "Templ" plugin in GoLand, the `templ` language server in Neovim. They highlight `.templ` files, complete Go in them, and report errors as you type. The extensions start templ's language server, `templ lsp`, from your `PATH`. Projects use templ as a tool of their module (`go tool templ`), so install the same version for the editor too: the version is on the `github.com/a-h/templ` line of the project's `go.mod`. ```sh X ``` ## Formatting `gofmt` (your editor runs it on save) formats Go; `go tool templ fmt .` formats `.templ` files. `go tool anetos dev` regenerates the templ code and the models' typed columns as you save, so you don't run the generators yourself. Next: [Choose a database](https://docs.anetos.dev/getting-started/databases/index.md). # Choose a database Source: https://docs.anetos.dev/getting-started/databases/ Anetos apps run on SQLite, PostgreSQL, MySQL and MariaDB. Pick one when you create the project; you can switch later. | Database | `anetos new` flag | Needs | |---|---|---| | SQLite | none (the default) | Nothing: the database is a file, `database/app.db` | | PostgreSQL | `--db=postgres` | A server, a database and a user | | MySQL or MariaDB | `--db=mysql` | A server, a database and a user | Anetos is tested on SQLite, PostgreSQL 16 and 17, MySQL 8.0, and MariaDB 10.11 and 11.8. ## SQLite: start here SQLite needs no server, so a new project runs at once. It suits development, tests (they use an in-memory database), and many apps in production: one server, with the database file on its disk. Its driver is pure Go: no C compiler, and the app stays one static binary. ## PostgreSQL or MySQL Choose a server database for apps that run on several servers, or when your host provides one. Install it (your system's packages, Docker, or a hosted service), then create a database and a user for the app, and another database for the tests: ```sh # PostgreSQL createdb blog createdb blog_test ``` ```sql -- MySQL or MariaDB CREATE DATABASE blog CHARACTER SET utf8mb4; CREATE DATABASE blog_test CHARACTER SET utf8mb4; CREATE USER 'blog'@'localhost' IDENTIFIED BY 'secret'; GRANT ALL ON blog.* TO 'blog'@'localhost'; GRANT ALL ON blog_test.* TO 'blog'@'localhost'; ``` On MySQL and MariaDB, `utf8mb4` is the character set that stores any text, emoji included. MySQL 8 and MariaDB 11.6+ use it by default, but an older MariaDB may default to latin1, which refuses most non-Latin text. The tables the schema builder creates (`s.Create` in a migration) are utf8mb4 either way; the database's own setting applies to tables made with SQL (SQL-file migrations, `s.Exec`), and `doctor` warns about text columns in another character set. `anetos new blog --db=postgres` then writes the `DB_*` settings in `.env` (`DB_NAME=blog`) and `.env.testing` (`blog_test`): set the user and password there. Some features use what each database has: full-text search works on all of them, ranked differently; search by meaning needs PostgreSQL with pgvector or MariaDB 11.7+ (SQLite compares every row, for small data; MySQL can't). Each guide says what it needs, and the app tells you at startup when the database lacks it. To switch an existing project, see [Connect to a database](https://docs.anetos.dev/guides/database/index.md#5-switch-a-project-to-postgresql-or-mysql). Next: [Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md); then, if you work with a coding agent, [Build with a coding agent](https://docs.anetos.dev/getting-started/coding-agents/index.md). # Build with a coding agent Source: https://docs.anetos.dev/getting-started/coding-agents/ Coding agents such as Claude Code, Codex, Cursor, Gemini CLI and GitHub Copilot can build an Anetos app with you. Every project `anetos new` makes is ready for them: it has instructions they read before they start, guides for the common tasks, the documentation at the project's version a command away, and one command that tells them whether a change works. ## Before you start - An Anetos project ([Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md)), or an existing one (step 5). - A coding agent that works in your project's folder. ## Steps ### 1. Start the agent in the project Open the agent in the project's folder, as you would for any other work. What `anetos new` wrote is already there, and committed with the rest: | File | Read by | Holds | |---|---|---| | `AGENTS.md` | every agent above | The project (stack, database, CSS framework), the commands, where things go, the conventions, and how to check a change | | `CLAUDE.md`, `GEMINI.md` | Claude Code, Gemini CLI | One line, `@AGENTS.md`, importing it | | `.agents/skills/anetos-*/SKILL.md` | Codex, Copilot, Cursor, Gemini CLI | Skills: step-by-step guides an agent loads only for a task that needs one | | `.claude/skills/anetos-*/SKILL.md` | Claude Code (and Copilot, Cursor) | The same skills | | `.mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.gemini/settings.json`, `.codex/config.toml` | Claude Code and Copilot; VS Code; Cursor; Gemini CLI; Codex | The project's MCP server, `go tool anetos mcp` (step 4) | The skills cover a resource (`anetos-crud`), accounts and access (`anetos-auth`), schema changes (`anetos-migration`), background work (`anetos-job`), pages and forms (`anetos-page`; in an API project, `anetos-api` for endpoints), tests (`anetos-test`), deploying (`anetos-deploy`), upgrading (`anetos-upgrade`) and plugins (`anetos-plugin`). ### 2. Ask for a feature Ask for what you want, in your words: "add a blog with posts and comments, only logged-in users can comment". `AGENTS.md` tells the agent to use the generators first (`make:crud`, `make:auth`), Anetos's conventions (typed handlers, typed columns, the components of `views/ui`), and to read the docs before guessing an API. ### 3. Let it read the docs and check its work Two commands do most of the work for an agent, and are as useful to you: ```sh go tool anetos docs --search "comments on a post" # which pages to read go tool anetos docs guides/relations # print one, as Markdown go tool anetos check # generate, build, vet, test, doctor ``` The pages `docs` prints are those of the Anetos version in your `go.mod`, built into the tool: no network, and never another version's. `check` stops at the first failing step and prints its output; with `--json` it prints one object an agent can read. `AGENTS.md` asks the agent to run it before calling a change done. In a fresh clone, with no `.env` (an agent's sandbox, CI), its doctor step checks the project's files only and says how to write one: `cp .env.example .env && go tool anetos key:generate`. ### 4. Let it ask the app Rather than reading every file to learn the app, an agent asks it: ```sh go tool anetos model:list # the models: tables, columns, relations go run . route:list --json # every route: handler, types, middleware go run . db:schema posts # a table as the database has it go run . about # versions, services, settings (secrets hidden) ``` `model:list` reads the code, so it works before the app does; the others are the app's own commands, which need a `.env`. Each takes `--json` (and `migrate:status`, `schedule:list`, `plugin:list` and `help` do too). The agent can also call them as tools: the project's MCP server, `go tool anetos mcp`, is in each agent's MCP settings (the files in the table above), with `docs_search`, `docs_read`, `model_list`, `route_list`, `db_schema`, `app_about`, `check` and `doctor`. It only reads and checks. Agents decide differently when a project's server starts: Claude Code asks you first; VS Code, Copilot CLI and Codex start it in a folder you trust (Workspace Trust, folder trust, a trusted project); Gemini CLI starts it unless you turned folder trust on and haven't trusted the folder. `about` hides what looks like a secret (by the setting's name, and a connection string's password), but shows the other settings, and an agent sends what it reads to its model's provider: read it before pasting it anywhere public. ### 5. Add the files to an existing project A project made before these files, or with `anetos new --no-agent`: ```sh go tool anetos agent:install # for every agent above go tool anetos agent:install --agent=claude,codex # or some of them ``` `claude` writes `CLAUDE.md` and `.claude/skills`; `gemini`, `GEMINI.md` and `.agents/skills`; `codex`, `copilot` and `cursor`, `.agents/skills`. An existing `CLAUDE.md` or `GEMINI.md` keeps its text, with the import added first. The files are those of exactly the agents you list: `--agent=claude` also removes Anetos's skills from `.agents/skills`. ### 6. Keep them current After upgrading Anetos, refresh them: ```sh go tool anetos agent:update ``` `go tool anetos doctor` warns when they're out of date. ## Make them yours - **Your own instructions** go in `AGENTS.md` outside its markers (`` and ``): `agent:update` rewrites only the text between them. Or in `CLAUDE.md` or `GEMINI.md`, after the import. - **Your own skills** go next to Anetos's, under names that don't start with `anetos-`: `agent:update` leaves them alone. - **Your own MCP servers** go in the same files: `agent:update` changes only the `anetos` entry. gopls has one for Go itself (`gopls mcp`). ## How it works `AGENTS.md` is the shared instruction file most coding agents read at the start of a session; Claude Code and Gemini CLI read their own file, which imports it. Skills follow the Agent Skills format (a folder with a `SKILL.md`, its `name` and `description` first): an agent sees each skill's description, and loads the rest only when a task matches, so they don't fill its context. `docs` reads the pages from the package `anetos.dev/anetos/docs`, in the core module, which holds `docs/site`'s Markdown; the tool is built from the version your `go.mod` pins, so the pages match the code. Its search matches whole words and ranks pages by them, rare ones counting most. An agent that browses rather than runs commands can read the same pages on the site: `https://docs.anetos.dev/llms.txt` lists them, each with a link to its Markdown copy, and `llms-full.txt` holds them all. A coding agent works on your code. `make:agent` is something else: it writes an AI agent that runs inside your app, for your users ([Add AI to your app](https://docs.anetos.dev/guides/ai/index.md)). ## Common problems | Problem | Cause | Fix | |---|---|---| | The agent writes code for another framework (its helpers, an ORM's methods Anetos doesn't have) | It guessed instead of reading | Ask it to read `AGENTS.md` and run `go tool anetos docs --search …` first; `check` catches what doesn't compile | | Copilot or Cursor lists each skill twice | Both folders, `.agents/skills` and `.claude/skills`, are there, and they read both | Install for the agents you use only: `go tool anetos agent:install --agent=cursor` removes Anetos's skills from `.claude/skills` | | `doctor` warns that agent files differ | An Anetos upgrade, or an edit between `AGENTS.md`'s markers | `go tool anetos agent:update` (move your edits outside the markers first) | | Gemini CLI doesn't follow `AGENTS.md` | No `GEMINI.md` | `go tool anetos agent:install --agent=gemini` | | The agent has no `anetos` tools | It hasn't been allowed to start the project's MCP server, or its settings file isn't there | Approve the `anetos` server in the agent's MCP settings (Codex: trust the project); `go tool anetos agent:install --agent=` writes the file | | The `anetos` server doesn't start: `go: no such tool "anetos"` | The agent started it outside the project (Claude's desktop app starts servers in your home folder) | Start it as `go -C tool anetos mcp`: in your own (user) settings, not the committed file | ## Next steps - [Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md), then ask your agent for its first feature. - [anetos tool reference](https://docs.anetos.dev/reference/cli/index.md): `docs`, `check`, `agent:install` and `agent:update`. # Your first app Source: https://docs.anetos.dev/getting-started/your-first-app/ - [Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md): One command writes a working app: a home page in a styled layout, sessions, CSRF protection, migrations, a queue, email, a test, and the files to deploy it. - [Project structure](https://docs.anetos.dev/getting-started/project-structure/index.md): A project is a Go module. Its folders follow what each part of the app does; the generators (`make:crud`, `make:auth`…) add their files in the same places. - [Add pages for a model](https://docs.anetos.dev/getting-started/crud/index.md): `make:crud` writes a model with its table, and the pages to list, show, create, edit and delete its rows, made of your app's UI components, with their routes and a test. You get working pages in a minute, and code you can read and change. - [Add accounts](https://docs.anetos.dev/getting-started/add-accounts/index.md): `make:auth` writes user accounts into your app: registration, login with "remember me", login with Google and GitHub, email verification, password reset, two-factor authentication, a settings page and API tokens, with their pages, emails, routes and tests. - [Test and build](https://docs.anetos.dev/getting-started/test-and-build/index.md): go test ./... # Create a project Source: https://docs.anetos.dev/getting-started/create-a-project/ One command writes a working app: a home page in a styled layout, sessions, CSRF protection, migrations, a queue, email, a test, and the files to deploy it. ## Create it ```sh anetos new blog cd blog ``` `anetos new` writes the project, downloads its dependencies and compiles its views. Its flags: | Flag | Default | Meaning | |---|---|---| | `--db` | `sqlite` | `postgres` or `mysql` for a server database ([Choose a database](https://docs.anetos.dev/getting-started/databases/index.md)) | | `--stack` | `web` | `api` for an app that serves JSON only ([below](https://docs.anetos.dev/getting-started/create-a-project/index.md#an-api-instead)) | | `--css` | `anetos` | The CSS framework: `anetos`, the starter theme; `none`, components without classes and an almost empty stylesheet, for your own CSS; `pico`, `bootstrap` or `bulma`, components in that framework's markup, with its files; `tailwind`, components with Tailwind CSS's classes, compiled by `anetos dev` ([Style your app](https://docs.anetos.dev/guides/styling/index.md#7-or-start-with-a-css-framework)) | | `--module` | the folder's name | The Go module path, such as `github.com/you/blog` | ## Run it ```sh go run . migrate # create the tables the framework uses go tool anetos dev # http://localhost:8080 ``` `migrate` creates the database (for SQLite, `database/app.db`) and the tables of sessions, the cache and the queue. `anetos dev` builds and runs the app, and rebuilds it when you save a file: the page in your browser reloads by itself. Keep it running in a terminal of its own. Open http://localhost:8080: a welcome page, in light or dark like your system. Edit `views/home.templ` and save to see it change; its text is in `locales/en/app.yaml`, ready for other languages. ## The app's commands The app is one program with commands. `go run . help` lists them: ```sh go run . help # every command go run . route:list # the URLs and their handlers go run . # run: the web server, the queue's workers and the scheduler ``` In production, the built binary takes the same commands (`./bin/blog migrate`). ## An API instead `--stack=api` writes an app that serves JSON only, for a mobile app or a front end built separately: ```sh anetos new shop --stack=api cd shop go run . migrate go tool anetos dev # http://localhost:8080/api/v1 ``` It has the same database, queue, mail, storage, scheduler, plugins and deploy files, but no views, static files, sessions or CSRF protection. Its routes are in `routes/api.go`, under `/api/v1`; `GET /api/v1` answers: ```json {"name":"shop","message":"Welcome to the Shop API."} ``` Every error is JSON problem details, whatever the client accepts (a missing URL's 404 too). Browsers on other origins may call the API once `HTTP_CORS_ORIGINS` in `.env` lists them, such as `http://localhost:5173`. `make:handler` writes JSON handlers in such a project, `make:auth` accounts that log in with API tokens ([Add accounts to an API](https://docs.anetos.dev/guides/api-accounts/index.md)), and `make:crud` JSON endpoints for a model ([Add pages for a model](https://docs.anetos.dev/getting-started/crud/index.md#in-an-api-project)). `openapi.json` describes the API in OpenAPI 3.1, for clients and tools, and the app serves it at `/api/v1/openapi.json`. `make:auth` and `make:crud` update it; after changing a route or a handler yourself, run `go run . openapi`: a test in `main_test.go` fails until you do ([Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md)). Next: [Project structure](https://docs.anetos.dev/getting-started/project-structure/index.md). # Project structure Source: https://docs.anetos.dev/getting-started/project-structure/ A project is a Go module. Its folders follow what each part of the app does; the generators (`make:crud`, `make:auth`…) add their files in the same places. ```text blog/ ├── main.go setup: the database, cache, queue, mail, server, routes… ├── main_test.go tests of the home page and the 404 page ├── plugins.go the plugins (anetos add) ├── app/ │ ├── handlers/ HTTP handlers: a request in, a page or JSON out │ └── models/ models: Go structs for tables, and their typed columns ├── database/ │ ├── migrations/ changes to the tables, in Go, in order │ └── factories/ test data ├── routes/ │ └── web.go URLs to handlers, with their middleware ├── views/ │ ├── layout.templ the page shell: the header, the flash message │ ├── home.templ the home page │ ├── errors.templ error pages (404, 500…) in the layout │ └── ui/ the components the pages are made of: cards, forms, buttons, tables… ├── locales/en/app.yaml the pages' text ├── public/ the web root: robots.txt, served at /robots.txt │ └── static/ CSS (app.css, the starter theme), the icon (favicon.svg) and other files, at /assets/ ├── deploy/ a systemd unit and the production settings ├── Dockerfile a container image ├── .env settings for your machine (not in git) ├── .env.example the settings, without secrets (in git) ├── AGENTS.md instructions for coding agents (CLAUDE.md and GEMINI.md import it) └── .agents/, .claude/ skills: coding agents' guides to common tasks ``` An API project (`anetos new --stack=api`) has `routes/api.go` instead of `routes/web.go`, `app/handlers/welcome.go` (the answer to `GET /api/v1`) instead of the home page, and no `views/` or `public/`; `locales/en/app.yaml` holds its messages, and `main_test.go` tests the welcome and a 404's JSON. ## How a request flows 1. `routes/web.go` matches the URL to a handler and runs the route's middleware: sessions and CSRF protection for pages. 2. The handler, in `app/handlers`, reads the request (typed input, validated) and the database (the models of `app/models`). 3. It renders a view of `views/`, inside `Layout`, or answers JSON. The views call the components of `views/ui`, which hold the markup and the class names ([Style your app](https://docs.anetos.dev/guides/styling/index.md)). [HTTP request lifecycle](https://docs.anetos.dev/concepts/http-request-lifecycle/index.md) explains each step. ## Settings Settings come from the environment, and in development from `.env`: `APP_KEY` (made fresh for each project), `DB_*`, `MAIL_*` and the rest. Tests read `.env.testing` instead (or nothing, for SQLite: an in-memory database). The [configuration reference](https://docs.anetos.dev/reference/configuration/index.md) lists every key. ## Generated files `*_templ.go` (from the `.templ` files) and `app/models/models_gen.go` (the models' typed columns) are written by `templ generate` and `anetos generate`; `anetos dev` and `anetos build` run both. Commit them, but don't edit them. The [CLI reference](https://docs.anetos.dev/reference/cli/index.md#anetos-new-directory) describes every file `anetos new` writes. Next: [Add pages for a model](https://docs.anetos.dev/getting-started/crud/index.md). # Add pages for a model Source: https://docs.anetos.dev/getting-started/crud/ `make:crud` writes a model with its table, and the pages to list, show, create, edit and delete its rows, made of your app's UI components, with their routes and a test. You get working pages in a minute, and code you can read and change. ## Generate them ```sh go tool anetos make:crud Post title:string body:text published:bool go run . migrate ``` Open http://localhost:8080/posts (the header links to it): an empty list with a "New post" button. Save the form empty to see the validation messages, then fill it in. Each field is `name:type`, then `:optional` or `:unique` if you like: | Type | Go | Column | Input | |---|---|---|---| | `string` | `string` | `VARCHAR(255)` | a text box | | `text` | `string` | `TEXT` | a text area | | `email` | `string` | `VARCHAR(255)` | an email box, checked with the `email` rule | | `int` | `int64` | `BIGINT` | a number box | | `float` | `float64` | double | a number box with decimals | | `bool` | `bool` | boolean | a checkbox | | `date` | `anetos.Date` | `DATE` | a date picker | Strings, texts, emails and dates are required; `:optional` lets them be empty. `:unique`, for required strings and emails and for dates, adds a unique index and the `unique` rule, so the form says "The email has already been taken." rather than failing. (An optional string is empty, not NULL, when left blank, and a blank number is 0: only one row could have either, so `make:crud` refuses them; add such an index by hand.) ```sh go tool anetos make:crud Customer name:string email:email:unique notes:text:optional since:date ``` ## What it writes For `Post`: | File | Holds | |---|---| | `app/models/post.go` | `Post`, with a field per column; `anetos generate` adds `PostCols` to `models_gen.go` | | `database/migrations/…_create_posts_table.go` | The `posts` table | | `app/handlers/posts.go` | `Posts`: `Index` (20 a page, newest first), `Show`, `New`, `Create`, `Edit`, `Update`, `Delete`; `PostInput`, the form, with its `validate` tags | | `views/posts.templ` | The list, a post's page, and the form for new and existing posts | | `routes/posts.go` | `routes.Posts`: the seven routes, named `posts.index`, `posts.show`… | | `locales/en/posts.yaml` | The pages' text: titles, buttons, the fields' labels, the messages | | `posts_test.go` | A test that creates, shows, edits and deletes a post through the pages | It also adds `Posts(pages)` to `routes/web.go` (the page group, with sessions and CSRF protection) and a link to the list in the layout's header. It never overwrites a file: if one exists, it writes nothing. In a project made before v0.5, which has no `views/ui`, it writes the components first, and says so ([Style your app](https://docs.anetos.dev/guides/styling/index.md#projects-made-before-v05)). | Route | URL | Does | |---|---|---| | `posts.index` | `GET /posts` | The list, with pages (`?page=2`) | | `posts.new` | `GET /posts/new` | The empty form | | `posts.create` | `POST /posts` | Creates the post, then shows it | | `posts.show` | `GET /posts/{id}` | The post, with Edit and Delete | | `posts.edit` | `GET /posts/{id}/edit` | The filled form | | `posts.update` | `PUT /posts/{id}` | Saves the changes | | `posts.delete` | `DELETE /posts/{id}` | Deletes the post, then lists the others | HTML forms send `PUT` and `DELETE` with a hidden `_method` field, which the project's `web.MethodOverride` reads. Each route is named after its handler (`h.Create` is `posts.create`). > **Coming from another framework?** Rails and Phoenix also call the > form `new` and saving it `create`, and Phoenix says `delete` where > Rails says `destroy`. Laravel's resource routes say `create` for the > form, `store` for saving it and `destroy` for deleting. Django's > generic views are classes (`CreateView`, `DeleteView`). ## Make it yours The code is plain Anetos code: change it as you would your own. - **Only for logged-in users.** After [`make:auth`](https://docs.anetos.dev/getting-started/add-accounts/index.md), move the `Posts(pages)` call from `routes/web.go` to the `loggedIn` group of `routes/auth.go` (`Posts(loggedIn)`): guests then go to the login page. Log a user in at the start of `TestPosts`, as its comment shows (`make:auth` wrote `factories.Users`), and show the header's link to logged-in users only, in `views/layout.templ` (importing `anetos.dev/anetos/auth` and the app's `app/models`): ```templ // illustrative if _, ok := auth.User[*models.User](ctx); ok { @navLink("posts.index", i18n.T(ctx, "posts.title")) } ``` - **Another column.** Add a migration (`go tool anetos make:migration add_slug_to_posts_table`), the field to `Post` and `PostInput`, a line to `fill`, and the input to `postFields` in the view. - **Other text.** Change `locales/en/posts.yaml`; add a language with its own folder ([Translations](https://docs.anetos.dev/guides/translations/index.md)). - **Look.** The pages call the components of `views/ui` (`ui.PageHeader`, `ui.Table`, `ui.Form`, `ui.Field`…) and have no class names. Change the colors in `public/static/app.css`, or a component's markup in `views/ui`, and every page follows ([Style your app](https://docs.anetos.dev/guides/styling/index.md)). `go test ./...` runs the test it wrote; keep it passing as you change the pages. ## In an API project In a project made with `anetos new --stack=api`, the same command writes JSON endpoints instead of pages: the model and its migration, `app/handlers/posts.go`, `routes/posts.go` and `posts_test.go`, and `Posts(api)` at the end of `Register` in `routes/api.go`. There are no views or catalog. | Route | URL | Answers | |---|---|---| | `api.posts.index` | `GET /api/v1/posts` | A page of posts | | `api.posts.create` | `POST /api/v1/posts` | `201` with the post, and its URL in `Location` | | `api.posts.show` | `GET /api/v1/posts/{id}` | The post | | `api.posts.update` | `PUT /api/v1/posts/{id}` | The post, every field replaced | | `api.posts.delete` | `DELETE /api/v1/posts/{id}` | `204` | A post is answered as `PostResponse`, a struct of the handlers' file (the ID, the fields, `created_at`, `updated_at`), never as the model: a column you add later shows only once you add it there. Optional dates are `null` when empty. The body of `POST` and `PUT` is JSON with the fields' names, checked with the same rules as the pages' form: a 422 lists the fields' errors. The list is `{"data": [...], "current_page": 1, "per_page": 20, "total": 1, "last_page": 1}`. Its query: - `?page=2`, and `?per_page=50` (20 by default, at most 100). - `?sort=title`, or `-title` for descending: `id`, `created_at`, `updated_at` or a field other than a text. Newest first (`-id`) by default; another value is a 422. Rows with the same value come newest first. Where an optional date's empty value (`NULL`) sorts depends on the database: first ascending in SQLite and MySQL, last in PostgreSQL. - An exact filter per field of type string, email, int, bool or date: `?published=true`. An empty value (`?title=`) doesn't filter. Texts and floats aren't filters: search and ranges are yours to add to `Index`. So fields can't be named `page`, `per_page` or `sort` in an API project. The endpoints are open to every client. After [`make:auth`](https://docs.anetos.dev/guides/api-accounts/index.md), for clients with a token only: 1. Move the `Posts(api)` call from `routes/api.go` to `routes/auth.go`, in the `loggedIn` group: `Posts(loggedIn)`. 2. If tokens should need abilities, require them on the routes, in `routes/posts.go` (with `anetos.dev/anetos/auth` imported): ```go // illustrative read := r.With(auth.RequireAbilities("posts:read")) write := r.With(auth.RequireAbilities("posts:write")) read.Get("/posts", web.H(h.Index)).Name("posts.index") write.Post("/posts", web.H(h.Create)).Name("posts.create").Status(http.StatusCreated) ``` 3. `posts_test.go` now gets 401: make its test sign up first, with `auth_test.go`'s helper, `app, _ := authRegister(t)` in place of `app := anetostest.New(t, setup)`. 4. `go run . openapi`, so `openapi.json` says they need a token. [Tutorial: build an API](https://docs.anetos.dev/getting-started/build-an-api/index.md) does it step by step, with records that belong to their user. `make:crud` updates `openapi.json`, the API's description, with the five operations, `PostResponse`, `PostInput` and `PagePostResponse` ([Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md)). Number and boolean fields are never required: a request without `price` saves 0, and a `PUT` without it sets it back to 0. Add a rule (`validate:"required"` refuses 0, so a positive `min:1` or a pointer field may suit better) where a value must be given. ## When to write pages by hand `make:crud` makes the common case quick: a table, a form, plain validation. Pages with relations, permissions or htmx start from its code, or from scratch: the [tutorial](https://docs.anetos.dev/getting-started/tutorial/index.md) builds issue pages by hand, step by step, and the [guides](https://docs.anetos.dev/guides/index.md) cover [forms](https://docs.anetos.dev/guides/forms/index.md), [validation](https://docs.anetos.dev/guides/validation/index.md), [queries](https://docs.anetos.dev/guides/queries/index.md) and [relations](https://docs.anetos.dev/guides/relations/index.md). Next: [Add accounts](https://docs.anetos.dev/getting-started/add-accounts/index.md). # Add accounts Source: https://docs.anetos.dev/getting-started/add-accounts/ `make:auth` writes user accounts into your app: registration, login with "remember me", login with Google and GitHub, email verification, password reset, two-factor authentication, a settings page and API tokens, with their pages, emails, routes and tests. ```sh go tool anetos make:auth go run . migrate ``` Open http://localhost:8080/register. The header now has links to log in and register, and, once you're logged in, your name, the settings and a logout button (`AccountMenu`, in `views/auth.templ`). In development, emails go to the `anetos dev` log: open the verification link from there. ## Where users land After logging in, users go to the page they asked for before logging in, or else to `/dashboard`; after registering, to `/dashboard`. To send them elsewhere, set `AUTH_HOME_URL` in `.env` and in production: ```sh AUTH_HOME_URL=/posts ``` or change the default in `auth.go`, which `make:auth` wrote: ```go // illustrative a, err := auth.New(app, models.Users, auth.WithDefaultHomeURL("/posts")) ``` The setting wins over the default, so each deployment can choose. ## Pages for logged-in users only `routes/auth.go` has a `loggedIn` group: guests who open its pages go to the login page and come back after logging in. Put your own routes there, such as `make:crud`'s (`Posts(loggedIn)`), and read the user in a handler with `auth.User[*models.User](c)`. Their tests then log a user in first, with the factory `make:auth` wrote: ```go // illustrative app := anetostest.New(t, setup) ada := anetostest.Create(app, factories.Users) anetostest.ActingAs(app, &ada).Get("/posts").AssertOK() ``` [Add accounts with make:auth](https://docs.anetos.dev/guides/accounts/index.md) describes every page, setting and file, and how to change them. Next: [Test and build](https://docs.anetos.dev/getting-started/test-and-build/index.md). # Test and build Source: https://docs.anetos.dev/getting-started/test-and-build/ ## Test ```sh go test ./... ``` Tests boot the app with test settings and a database of their own (SQLite in memory, or `.env.testing`'s), and send requests to it like a browser, through `anetostest`: ```go // illustrative func TestHome(t *testing.T) { app := anetostest.New(t, setup) app.Get("/").AssertOK().AssertSee("Welcome") } ``` Each test runs in a transaction that is rolled back, so tests don't see each other's rows. `make:crud` and `make:auth` wrote tests for their pages; add one for each page and form you write. [Test your app](https://docs.anetos.dev/guides/testing/index.md) shows forms, logged-in users, emails and queue jobs. ## Build ```sh go tool anetos build # bin/blog ./bin/blog migrate ./bin/blog # the web server, the queue's workers and the scheduler ``` `anetos build` compiles the views and the models' columns, then builds one static binary that holds the migrations, the views and the files of `public/` and `locales/`. A server needs only the binary and its settings (the environment, or a file: `deploy/production.env.example` lists them). [Deploy](https://docs.anetos.dev/guides/deployment/index.md) runs it on a server with systemd, in a container (the project has a `Dockerfile`), or on Fly.io and Render. Before going live, run `./bin/blog doctor` with the production settings: it reports unsafe ones (`SESSION_SECURE=false`, a database reached without TLS, the log mail driver…) and migrations that haven't run. See [Secure your app](https://docs.anetos.dev/guides/security/index.md). Next: the [tutorial](https://docs.anetos.dev/getting-started/tutorial/index.md) builds a whole app, step by step. # Tutorial: build an issue tracker Source: https://docs.anetos.dev/getting-started/tutorial/ In this tutorial you build a small issue tracker: people sign up, open issues, comment on them, search them, and get an email when someone comments on theirs. Then you build it for production. Along the way you use most of what an Anetos app is made of: models and migrations, handlers and routes, templ views and forms, accounts, htmx, full-text search, events, queue jobs, email, and tests. The whole tutorial takes about an hour. Short on time? Parts 1 to 3, then 7, take about 30 minutes and give you an app with accounts and issues, ready to deploy. | Part | You add | |---|---| | [1. Create the app](https://docs.anetos.dev/getting-started/tutorial/01-create-the-app.md) | The project, running with live reload | | [2. Accounts](https://docs.anetos.dev/getting-started/tutorial/02-accounts.md) | Sign-up, login, email verification, settings | | [3. Issues](https://docs.anetos.dev/getting-started/tutorial/03-issues.md) | A model, a migration, pages to list, open and edit issues, a test | | [4. The issue page](https://docs.anetos.dev/getting-started/tutorial/04-the-issue-page.md) | Comments posted with htmx, closing issues, an event | | [5. Search](https://docs.anetos.dev/getting-started/tutorial/05-search.md) | A full-text index and a search page | | [6. Email the author](https://docs.anetos.dev/getting-started/tutorial/06-email-the-author.md) | A mail, and a queued listener that sends it | | [7. Deploy](https://docs.anetos.dev/getting-started/tutorial/07-deploy.md) | The production build, as a binary and an image | ## Before you start - Go 1.26 or later (`go version`). - The `anetos` tool: ```sh go install anetos.dev/anetos/cli/cmd/anetos@v0.5.0-rc.1 ``` - Some Go: structs, methods, errors. You don't need to know Anetos. No database server is needed: the app uses SQLite, in a file. ## Reading the code Each block shows code to put in a file, and the text says where. A new file starts with its package line, named after its folder (`package handlers` in `app/handlers`), then the imports the tutorial shows with it. When a block replaces generated code, the text says which. ## The finished code [`examples/tutorial`](https://github.com/anetos-dev/anetos/tree/main/examples/tutorial) is the app at the end of part 6, compiled and tested with every change to Anetos. If something doesn't work, compare your files with it. When you're done, [`examples/tracker`](https://github.com/anetos-dev/anetos/tree/main/examples/tracker) is a bigger version of the same app: projects with members and roles, labels, files, an admin, a JSON API. It's there to read when you build your own. # 1. Create the app Source: https://docs.anetos.dev/getting-started/tutorial/01-create-the-app/ You create the project, run it, and look around. ## Create the project ```sh anetos new tracker cd tracker ``` `anetos new` writes a project, downloads its dependencies and generates its views. The project is a Go module named `tracker`; its packages import each other as `tracker/app/models` and so on. It pins the `anetos` tool in `go.mod`: in the project, run it as `go tool anetos`. ## Run it Create the database tables the framework needs (sessions, the cache, the queue's jobs), then start the app: ```sh go run . migrate go tool anetos dev ``` Open http://localhost:8080. `anetos dev` rebuilds and restarts the app when you save a file, and reloads the page in the browser. Leave it running in a terminal of its own for the rest of the tutorial. ## Look around | Path | Holds | |---|---| | `main.go` | `setup`: connects the database, then adds the migrations, cache, queue, events, mailer, storage, scheduler, web server and routes. `main` calls it, and so do the tests | | `.env` | The settings, for your machine: `APP_KEY` (a fresh key), `DB_NAME=database/app.db`… It stays out of git; `.env.example` lists the settings | | `routes/web.go` | The routes: URLs to handlers | | `app/handlers/home.go` | The home page's handler | | `views/layout.templ`, `views/home.templ` | The page shell (a header with the app's links) and the home page, as [templ](https://templ.guide) components: typed Go functions that render HTML; `views/errors.templ` shows errors (a 404…) in the same shell | | `views/ui/` | The components the pages are made of (`ui.Card`, `ui.Field`, `ui.Button`…): their markup and classes, so the pages need none | | `public/static/app.css` | The styles: Anetos's starter theme, light and dark, with no build step ([Style your app](https://docs.anetos.dev/guides/styling/index.md)) | | `locales/en/app.yaml` | The home page's text | | `database/migrations/` | Migrations: changes to the database's tables, in Go | | `main_test.go` | Tests that request the home page and a missing page | `go run . help` lists the app's commands: `run` (the default: the web server, the queue's workers and the scheduler), `migrate`, `route:list` and more. The [CLI reference](https://docs.anetos.dev/reference/cli/index.md) has the full list of files and commands. Run the test: ```sh go test ./... ``` Tests boot the app with an in-memory database, so they don't touch `database/app.db`. Next: [2. Accounts](https://docs.anetos.dev/getting-started/tutorial/02-accounts.md). # 2. Accounts Source: https://docs.anetos.dev/getting-started/tutorial/02-accounts/ Issues have authors, so the app needs accounts. One command writes them. ## Add accounts ```sh go tool anetos make:auth go run . migrate ``` `make:auth` writes the code of accounts into your project, as files you own and can change: | File | Holds | |---|---| | `app/models/user.go` | `User`, and `models.Users`, which tells package `auth` how to find and update users | | `app/handlers/auth.go`, `app/handlers/settings.go` | Registration, login (with a password, Google or GitHub), email verification, password reset, two-factor authentication, API tokens, the settings page | | `views/auth.templ`, `views/settings.templ` | Their pages, and `AccountMenu`, the header's links to log in, register and log out, which `make:auth` adds to `views/layout.templ` | | `routes/auth.go` | Their routes. Pages for logged-in users go in its `loggedIn` group | | `auth_test.go` | Their tests | | `database/factories/users.go` | `factories.Users`, which makes users for your own tests | `migrate` creates the `users`, `api_tokens` and `social_accounts` tables. ## Try it Open http://localhost:8080/register and sign up. The app sends a link to verify your address: in development, emails go to the log (`MAIL_DRIVER=log`), so look in the terminal where `anetos dev` runs and open the link. You land on `/dashboard` (`AUTH_HOME_URL` sets another page; part 3 shows how); `/settings` changes your name, password, language and time zone. `go test ./...` now runs the accounts' tests too. The [accounts guide](https://docs.anetos.dev/guides/accounts/index.md) explains what's there and how to change it. Next: [3. Issues](https://docs.anetos.dev/getting-started/tutorial/03-issues.md). # 3. Issues Source: https://docs.anetos.dev/getting-started/tutorial/03-issues/ You add the issues: a model and its table, pages to list, open and edit them, and a test. > **Tip:** `go tool anetos make:crud Issue title:string body:text` > writes a model, its table, and pages to list, show, create, edit and > delete its rows, in one command ([Add pages for a > model](https://docs.anetos.dev/getting-started/crud/index.md)). Here you write them by hand, to see each piece > and make issues your own: they have an author and a status. ## The model and its table ```sh go tool anetos make:model Issue --migration ``` This writes `app/models/issue.go` and a migration, `database/migrations/_create_issues_table.go`. Give the model its fields: ```go // Issue is a bug or a task. `go tool anetos generate` writes its typed columns // (IssueCols) and relations (IssueRels) to models_gen.go. type Issue struct { db.Model // id, created_at, updated_at Title string `db:"title"` Body string `db:"body"` Status string `db:"status"` // "open" or "closed" AuthorID int64 `db:"author_id"` Author *User `rel:"belongs_to"` // by AuthorID } ``` (Copied from [`examples/tutorial/app/models/issue.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/models/issue.go), region `model`.) `db.Model` adds the `id`, `created_at` and `updated_at` columns. Each field's `db` tag names its column; `Author` is a relation, loaded when you ask for it, by the `author_id` column (the [relations guide](https://docs.anetos.dev/guides/relations/index.md) has the others). Then the table, in the migration's first function (the second one undoes it): ```go func(s *migrate.Schema) error { return s.Create("issues", func(t *migrate.Table) { t.ID() t.String("title", 200) t.Text("body") t.String("status", 10).Default("open") t.ForeignID("author_id").References("users") t.Timestamps() }) }, ``` (Copied from [`examples/tutorial/database/migrations/2026_10_06_142537_create_issues_table.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/database/migrations/2026_10_06_142537_create_issues_table.go), region `migration`.) Create the table, and the model's typed columns: ```sh go run . migrate go tool anetos generate ``` `anetos generate` (which `anetos dev` also runs when you save) writes the model's typed columns and relations to `app/models/models_gen.go`, so queries say `models.IssueCols.Title` and `models.IssueRels.Author`, and the compiler catches a misspelled column. ## The handlers ```sh go tool anetos make:handler Issues ``` It wrote `app/handlers/issues.go`, with the `Issues` type and an `Index` method. The handlers below need these imports: ```go import ( "net/http" "anetos.dev/anetos/auth" "anetos.dev/anetos/db" "anetos.dev/anetos/web" "tracker/app/models" "tracker/views" ) ``` (Copied from [`examples/tutorial/app/handlers/issues.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issues.go), region `imports`.) Replace `Index` with the list: ```go // IssueList is the input of GET /issues: the page number, from ?page=. type IssueList struct { Page int `query:"page"` } // Index lists the issues, newest first, 20 a page. func (Issues) Index(c *web.Ctx, in IssueList) (web.Responder, error) { page, err := db.Query[models.Issue](c). With(models.IssueRels.Author). OrderBy(models.IssueCols.ID.Desc()). Paginate(in.Page, 20) if err != nil { return nil, err } return web.Render(views.IssuesPage(page)), nil } ``` (Copied from [`examples/tutorial/app/handlers/issues.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issues.go), region `index`.) `web.H` (in the routes below) turns a function of an input struct into a handler: it fills `IssueList` from the request (`query:"page"`), and renders what the function returns. `Paginate` counts the issues and returns one page, with `With` loading their authors in one more query. Below it, opening an issue: ```go // IssueInput is the issue form. Its validate tags are checked before the // handler runs: an invalid form goes back with its errors. type IssueInput struct { Title string `json:"title" validate:"required|max:200"` Body string `json:"body" validate:"max:10000"` } // New shows the form for a new issue. func (Issues) New(c *web.Ctx) error { return c.Render(http.StatusOK, views.IssueForm(models.Issue{})) } // Create opens an issue by the logged-in user. func (Issues) Create(c *web.Ctx, in IssueInput) (web.Responder, error) { u, err := auth.Current[*models.User](c) if err != nil { return nil, err } issue := models.Issue{Title: in.Title, Body: in.Body, Status: "open", AuthorID: u.ID} if err := db.Create(c, &issue); err != nil { return nil, err } c.Session().Flash("status", "Issue opened.") return web.RedirectRoute("issues.index"), nil } ``` (Copied from [`examples/tutorial/app/handlers/issues.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issues.go), region `create`.) The form's fields fill `IssueInput` by their `json` names, and its `validate` tags are checked first: when they fail, the browser goes back to the form with the messages and what was typed, and `Create` doesn't run. `auth.Current` is the logged-in user. And editing one, which only its author may: ```go // IssueID reads {id} from the path. type IssueID struct { ID int64 `path:"id"` } // UpdateIssue is the input of PUT /issues/{id}: the path and the form. type UpdateIssue struct { IssueID IssueInput } // own returns the issue if the logged-in user wrote it: others get a // 403, and an issue that doesn't exist is a 404 (db.ErrNotFound). func own(c *web.Ctx, id int64) (models.Issue, error) { issue, err := db.Find[models.Issue](c, id) if err != nil { return issue, err } u, err := auth.Current[*models.User](c) if err != nil { return issue, err } if issue.AuthorID != u.ID { return issue, web.Error(http.StatusForbidden, "Only the issue's author can change it.") } return issue, nil } // Edit shows the form to edit an issue. func (Issues) Edit(c *web.Ctx, in IssueID) (web.Responder, error) { issue, err := own(c, in.ID) if err != nil { return nil, err } return web.Render(views.IssueForm(issue)), nil } // Update saves the issue. func (Issues) Update(c *web.Ctx, in UpdateIssue) (web.Responder, error) { issue, err := own(c, in.ID) if err != nil { return nil, err } issue.Title, issue.Body = in.Title, in.Body if err := db.Update(c, &issue); err != nil { return nil, err } c.Session().Flash("status", "Issue saved.") return web.RedirectRoute("issues.index"), nil } ``` (Copied from [`examples/tutorial/app/handlers/issues.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issues.go), region `edit`.) `db.Find` returns `db.ErrNotFound` for an issue that doesn't exist, which becomes a 404; `web.Error` makes a 403. ## The pages Create `views/issues.templ`, in package `views`, with these imports: ```templ import ( "fmt" "anetos.dev/anetos/db" "anetos.dev/anetos/view" "anetos.dev/anetos/web" "tracker/app/models" "tracker/views/ui" ) ``` (Copied from [`examples/tutorial/views/issues.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/issues.templ), region `imports`.) The list: ```templ // IssuesPage lists a page of issues, with links to the pages around it. templ IssuesPage(page db.Page[models.Issue]) { @Layout("Issues") { @ui.PageHeader("Issues", "") { @ui.LinkButton(web.MustURL(ctx, "issues.new"), ui.Primary) { New issue } } if page.Total == 0 { @ui.Empty("No issues yet.") } else { @ui.Table() { for _, issue := range page.Data { @statusBadge(issue.Status) { issue.Title } #{ issue.ID } by { issue.Author.Name } } } } if page.LastPage > 1 { @ui.Pagination(ui.PagesOf(ctx, page, "Pages", "", "Newer", "Older")) } } } // statusBadge shows an issue's status: green while it's open. templ statusBadge(status string) { if status == "open" { @ui.Badge(ui.Success) { { status } } } else { @ui.Badge(ui.Neutral) { { status } } } } ``` (Copied from [`examples/tutorial/views/issues.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/issues.templ), region `list`.) The titles link to each issue's page, which you add in part 4. The form, for new issues and edits: ```templ // IssueForm opens an issue, or edits one when issue.ID is set. After a // failed post, view.Old refills the fields and view.Errors has the // messages. templ IssueForm(issue models.Issue) { @Layout("Issue") { @ui.Narrow() { if issue.ID == 0 {

New issue

@ui.Card("") { @ui.Form(web.MustURL(ctx, "issues.create"), "POST", nil) { @issueFields(issue) } } } else {

Edit #{ issue.ID }

@ui.Card("") { @ui.Form(web.MustURL(ctx, "issues.update", issue.ID), "PUT", nil) { @issueFields(issue) } } } } } } templ issueFields(issue models.Issue) { @ui.Field("title", "Title", "") { @ui.Input("title", "", view.Old(ctx, "title", issue.Title), nil) } @ui.Field("body", "Description", "") { @ui.Textarea("body", view.Old(ctx, "body", issue.Body), templ.Attributes{"rows": "6"}) } @ui.Button(ui.Primary, nil) { Save } } ``` (Copied from [`examples/tutorial/views/issues.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/issues.templ), region `form`.) The pages are made of the components in `views/ui`, which `anetos new` wrote: `ui.PageHeader`, `ui.Table`, `ui.Badge`, `ui.Card`, `ui.Field`… They carry the markup and the starter theme's classes (in `public/static/app.css`), so the pages need none, and another design CSS framework restyles them ([Style your app](https://docs.anetos.dev/guides/styling/index.md)). `ui.Form` adds the token that protects forms from other sites, and, since HTML forms can't send PUT, says the method in a field. `ui.Field` shows the field's message when validation fails, and `view.Old` puts back what was typed. ## The routes The pages are for logged-in users: in `routes/auth.go`, add the routes at the end of the `loggedIn` group, after the `/confirm-password` ones: ```go // The tracker's pages, for logged-in users. var issues handlers.Issues loggedIn.Get("/issues", web.H(issues.Index)).Name("issues.index") loggedIn.Get("/issues/new", issues.New).Name("issues.new") loggedIn.Post("/issues", web.H(issues.Create)).Name("issues.create") loggedIn.Get("/issues/{id}/edit", web.H(issues.Edit)).Name("issues.edit") loggedIn.Put("/issues/{id}", web.H(issues.Update)).Name("issues.update") ``` (Copied from [`examples/tutorial/routes/auth.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/routes/auth.go), region `routes-issues`.) Each route has a name, which `web.URL` and `web.RedirectRoute` use, so URLs are written once. Guests who open these pages go to the login page. Link to the list from the header: in `views/layout.templ`, add a line to the nav, below the home page's: ```templ @navLink("issues.index", "Issues") ``` (Copied from [`examples/tutorial/views/layout.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/layout.templ), region `nav-issues`.) `navLink` (in the layout) marks the link as the current page on the issue pages, whose route names start with `issues.`. ## Try it Open http://localhost:8080/issues. Open an issue with an empty title (the form says what's wrong), then with one. Edit it. > **Tip:** Logging in leads to `/dashboard`. To land on the issues > instead, add `AUTH_HOME_URL=/issues` to `.env`, or change the default > in `auth.go`: `auth.WithDefaultHomeURL("/issues")`. ## Test it Tests need users: `make:auth` wrote a factory for them, `factories.Users` in `database/factories/users.go`, which makes verified users (`User 1`, `user1@example.com`…). Then create `issues_test.go` next to `main.go`, in package `main`: ```go import ( "net/url" "testing" "anetos.dev/anetos/anetostest" "tracker/app/models" "tracker/database/factories" ) ``` (Copied from [`examples/tutorial/issues_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/issues_test.go), region `imports`.) ```go func TestIssues(t *testing.T) { app := anetostest.New(t, setup) ada := anetostest.Create(app, factories.Users) anetostest.ActingAs(app, &ada) // logged in for the requests that follow app.Get("/issues/new").AssertOK() // Invalid: back to the form, with the errors. app.PostForm("/issues", url.Values{"title": {""}}). AssertRedirect("/issues/new"). AssertValidationErrors("title") // Valid: to the list, which shows it. app.PostForm("/issues", url.Values{"title": {"Login fails"}, "body": {"Since Tuesday."}}). AssertRedirect("/issues"). Follow(). AssertSee("Issue opened.", "Login fails", "by "+ada.Name) anetostest.AssertDatabaseHas[models.Issue](app, models.IssueCols.Title.Eq("Login fails"), models.IssueCols.AuthorID.Eq(ada.ID)) // Only its author edits it. app.PutForm("/issues/1", url.Values{"title": {"Login fails on Safari"}}).AssertRedirect("/issues") grace := anetostest.Create(app, factories.Users) anetostest.ActingAs(app, &grace) app.Get("/issues/1/edit").AssertForbidden() app.Get("/issues/2/edit").AssertNotFound() // Guests go to the login page. app.PostForm("/logout", nil) app.Get("/issues").AssertRedirect("/login") } ``` (Copied from [`examples/tutorial/issues_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/issues_test.go), region `test`.) `anetostest.New` boots the app with an in-memory database, migrated, and `ActingAs` logs a user in. The requests go through the whole app, middleware and CSRF protection included, as a browser's would. ```sh go test ./... ``` The [testing guide](https://docs.anetos.dev/guides/testing/index.md) has the other assertions. Next: [4. The issue page](https://docs.anetos.dev/getting-started/tutorial/04-the-issue-page.md). # 4. The issue page Source: https://docs.anetos.dev/getting-started/tutorial/04-the-issue-page/ Each issue gets its page, with comments that post without reloading the page, and a button to close it. ## Comments ```sh go tool anetos make:model Comment --migration ``` The model, and an event the app emits when someone comments (part 6 sends an email on it): ```go // Comment is a reply on an issue. type Comment struct { db.Model IssueID int64 `db:"issue_id"` AuthorID int64 `db:"author_id"` Body string `db:"body"` Author *User `rel:"belongs_to"` // by AuthorID } // CommentAdded is an event, emitted when someone comments on an issue: // other parts of the app can react to it (events.On). type CommentAdded struct { CommentID int64 } ``` (Copied from [`examples/tutorial/app/models/comment.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/models/comment.go), region `model`.) Its table. A comment belongs to an issue: deleting the issue deletes its comments (`CascadeOnDelete`): ```go func(s *migrate.Schema) error { return s.Create("comments", func(t *migrate.Table) { t.ID() t.ForeignID("issue_id").Constrained().CascadeOnDelete() t.ForeignID("author_id").References("users") t.Text("body") t.Timestamps() }) }, ``` (Copied from [`examples/tutorial/database/migrations/2026_10_06_142838_create_comments_table.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/database/migrations/2026_10_06_142838_create_comments_table.go), region `migration`.) ```sh go run . migrate ``` ## The handlers Create `app/handlers/issue_page.go`, in package `handlers`: ```go import ( "context" "anetos.dev/anetos/auth" "anetos.dev/anetos/db" "anetos.dev/anetos/events" "anetos.dev/anetos/web" "tracker/app/models" "tracker/views" ) ``` (Copied from [`examples/tutorial/app/handlers/issue_page.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issue_page.go), region `imports`.) The page: ```go // Show is an issue's page: the issue and its comments, oldest first. func (Issues) Show(c *web.Ctx, in IssueID) (web.Responder, error) { issue, err := db.Query[models.Issue](c).With(models.IssueRels.Author).Find(in.ID) if err != nil { return nil, err // db.ErrNotFound: 404 } comments, err := db.Query[models.Comment](c). Where(models.CommentCols.IssueID.Eq(issue.ID)). With(models.CommentRels.Author). OrderBy(models.CommentCols.ID.Asc()). Get() if err != nil { return nil, err } return web.Render(views.IssuePage(issue, comments)), nil } ``` (Copied from [`examples/tutorial/app/handlers/issue_page.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issue_page.go), region `show`.) Adding a comment: ```go // CommentInput is a new comment on the issue {id}. type CommentInput struct { IssueID Body string `json:"body" validate:"required|max:10000"` } // Comment adds a comment, and emits CommentAdded. With htmx, the comment // is sent back to add to the page; otherwise the browser reloads it. func (Issues) Comment(c *web.Ctx, in CommentInput) (web.Responder, error) { issue, err := db.Find[models.Issue](c, in.ID) if err != nil { return nil, err } u, err := auth.Current[*models.User](c) if err != nil { return nil, err } comment := models.Comment{IssueID: issue.ID, AuthorID: u.ID, Body: in.Body} err = db.Tx(c, func(ctx context.Context) error { // the comment and its event, or neither if err := db.Create(ctx, &comment); err != nil { return err } return events.Emit(ctx, models.CommentAdded{CommentID: comment.ID}) }) if err != nil { return nil, err } if c.IsHTMX() { comment.Author = u return web.Render(views.CommentItem(comment)), nil } return web.RedirectRoute("issues.show", issue.ID), nil } ``` (Copied from [`examples/tutorial/app/handlers/issue_page.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issue_page.go), region `comment`.) The comment and its event are written in one transaction (`db.Tx`): listeners that send email wait for it to commit, so nobody is told about a comment that wasn't saved. A request from htmx (`c.IsHTMX`) gets the new comment alone, to add to the page. Closing and reopening, which `own` (from part 3) keeps to the author: ```go // StatusInput closes or reopens the issue {id}. type StatusInput struct { IssueID Status string `json:"status" validate:"required|in:open,closed"` } // SetStatus closes or reopens an issue (its author only), and goes back // to its page. func (Issues) SetStatus(c *web.Ctx, in StatusInput) (web.Responder, error) { issue, err := own(c, in.ID) if err != nil { return nil, err } issue.Status = in.Status if err := db.Update(c, &issue); err != nil { return nil, err } return web.RedirectRoute("issues.show", issue.ID), nil } ``` (Copied from [`examples/tutorial/app/handlers/issue_page.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/handlers/issue_page.go), region `status`.) ## The page Create `views/issue_page.templ`, in package `views`: ```templ import ( "fmt" "anetos.dev/anetos/auth" "anetos.dev/anetos/view" "anetos.dev/anetos/web" "tracker/app/models" "tracker/views/ui" ) ``` (Copied from [`examples/tutorial/views/issue_page.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/issue_page.templ), region `imports`.) ```templ // IssuePage shows an issue with its comments. Its author gets the // buttons to edit, close and reopen it. templ IssuePage(issue models.Issue, comments []models.Comment) { @Layout(issue.Title) {

← Issues

@ui.PageHeader(issue.Title, fmt.Sprintf("#%d, opened by %s", issue.ID, issue.Author.Name)) { @ui.Cluster() { @statusBadge(issue.Status) if u, ok := auth.User[*models.User](ctx); ok && u.ID == issue.AuthorID { @ui.LinkButton(web.MustURL(ctx, "issues.edit", issue.ID), ui.Secondary) { Edit } @ui.InlineForm(web.MustURL(ctx, "issues.status", issue.ID), "POST", nil) { if issue.Status == "open" { @ui.Button(ui.Primary, nil) { Close } } else { @ui.Button(ui.Primary, nil) { Reopen } } } } } } @ui.Card("") { @ui.Multiline(issue.Body) }

Comments

for _, c := range comments { @CommentItem(c) }
// With htmx, the form posts in the background and the new comment // is added to the list; without JavaScript, it posts as usual. @ui.Card("") { @ui.Form(web.MustURL(ctx, "comments.create", issue.ID), "POST", templ.Attributes{ "hx-post": web.MustURL(ctx, "comments.create", issue.ID), "hx-target": "#comments", "hx-swap": "beforeend", "hx-on::after-request": "if (event.detail.successful) this.reset()", }) { @ui.Field("body", "Add a comment", "") { @ui.Textarea("body", view.Old(ctx, "body"), templ.Attributes{"rows": "3", "required": true}) } @ui.Button(ui.Primary, nil) { Comment } } } } } // CommentItem is a comment: in the list, and what htmx adds to it. templ CommentItem(c models.Comment) {
@ui.Card("") { { c.Author.Name }
@ui.Multiline(c.Body)
}
} ``` (Copied from [`examples/tutorial/views/issue_page.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/issue_page.templ), region `page`.) `ui.InlineForm` keeps the status form's hidden field and button on one line, beside Edit. The comment form works without JavaScript. With htmx, which the layout loads (`ui.Form` takes its attributes), `hx-post` sends it in the background, `hx-target` and `hx-swap` add the answer to the list, and the form clears. The CSRF token goes along in a header the layout sets. If the server refuses a comment (a 422 for a failed validation), htmx leaves the page as it is: the textarea's `required` stops the common case, an empty comment, in the browser. ## The routes In `routes/auth.go`, below the routes of part 3: ```go loggedIn.Get("/issues/{id}", web.H(issues.Show)).Name("issues.show") loggedIn.Post("/issues/{id}/comments", web.H(issues.Comment)).Name("comments.create") loggedIn.Post("/issues/{id}/status", web.H(issues.SetStatus)).Name("issues.status") ``` (Copied from [`examples/tutorial/routes/auth.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/routes/auth.go), region `routes-issue-page`.) Open an issue from the list, comment on it, close it. ## Test it A factory for issues, in `database/factories/factories.go`: first these imports, below the package line, ```go import ( "fmt" "anetos.dev/anetos/db/factory" "tracker/app/models" ) ``` (Copied from [`examples/tutorial/database/factories/factories.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/database/factories/factories.go), region `imports`.) then the factory: ```go // Issues are open issues. Set AuthorID: // // factories.Issues.With(func(i *models.Issue) { i.AuthorID = u.ID }) var Issues = factory.New(func(n int) models.Issue { return models.Issue{Title: fmt.Sprintf("Issue %d", n), Body: "Steps to reproduce.", Status: "open"} }) ``` (Copied from [`examples/tutorial/database/factories/factories.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/database/factories/factories.go), region `issues`.) And `issue_page_test.go`, in package `main`: ```go import ( "net/url" "testing" "anetos.dev/anetos/anetostest" "tracker/app/models" "tracker/database/factories" ) ``` (Copied from [`examples/tutorial/issue_page_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/issue_page_test.go), region `imports`.) ```go func TestIssuePage(t *testing.T) { app := anetostest.New(t, setup) ada := anetostest.Create(app, factories.Users) issue := anetostest.Create(app, factories.Issues.With(func(i *models.Issue) { i.AuthorID = ada.ID })) anetostest.ActingAs(app, &ada) app.Get("/issues/1").AssertOK().AssertSee(issue.Title, "Close") app.PostForm("/issues/1/comments", url.Values{"body": {"Same on Firefox."}}). AssertRedirect("/issues/1"). Follow(). AssertSee("Same on Firefox.") // With htmx, the comment comes back alone, to add to the list. app.WithHeader("HX-Request", "true") app.PostForm("/issues/1/comments", url.Values{"body": {"Fixed."}}). AssertOK(). AssertSee("
", "Fixed."). AssertDontSee("Search @ui.Form(web.MustURL(ctx, "search"), "GET", templ.Attributes{"role": "search"}) { @ui.Cluster() { @ui.Input("q", "search", q, templ.Attributes{"aria-label": "Search issues"}) @ui.Button(ui.Primary, nil) { Search } } } if q != "" && len(found) == 0 { @ui.Empty("No issues match “" + q + "”.") } if len(found) > 0 { @ui.Table() { for _, issue := range found { @statusBadge(issue.Status) { issue.Title } #{ issue.ID } by { issue.Author.Name } } } } } } ``` (Copied from [`examples/tutorial/views/search.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/search.templ), region `page`.) The route, in `routes/auth.go` below the others: ```go loggedIn.Get("/search", web.H(issues.Search)).Name("search") ``` (Copied from [`examples/tutorial/routes/auth.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/routes/auth.go), region `routes-search`.) Last, a search box on every page. In `views/layout.templ`, add it to the header, below the `@ui.Nav` block (before `@AccountMenu()`): ```templ
``` (Copied from [`examples/tutorial/views/layout.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/layout.templ), region `nav-search`.) ## Test it `search_test.go`, in package `main`: ```go import ( "testing" "anetos.dev/anetos/anetostest" "tracker/app/models" "tracker/database/factories" ) ``` (Copied from [`examples/tutorial/search_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/search_test.go), region `imports`.) ```go func TestSearch(t *testing.T) { app := anetostest.New(t, setup) ada := anetostest.Create(app, factories.Users) issues := factories.Issues.With(func(i *models.Issue) { i.AuthorID = ada.ID }) anetostest.Create(app, issues.With(func(i *models.Issue) { i.Title, i.Body = "Checkout fails", "The payment form spins." })) anetostest.Create(app, issues.With(func(i *models.Issue) { i.Title = "Typo in the footer" })) anetostest.ActingAs(app, &ada) app.Get("/search?q=payment").AssertOK().AssertSee("Checkout fails").AssertDontSee("Typo in the footer") app.Get("/search?q=zebra").AssertSee("No issues match “zebra”.") } ``` (Copied from [`examples/tutorial/search_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/search_test.go), region `test`.) > **Note:** On MySQL and MariaDB, full-text indexes only see committed > rows, and each test runs in a transaction: a search test there needs > `anetostest.WithoutTransaction()`. The tutorial's SQLite has no such > limit. The [search guide](https://docs.anetos.dev/guides/search/index.md) covers languages, ranking and the other databases. Next: [6. Email the author](https://docs.anetos.dev/getting-started/tutorial/06-email-the-author.md). # 6. Email the author Source: https://docs.anetos.dev/getting-started/tutorial/06-email-the-author/ When someone comments on an issue, its author gets an email. The comment handler of part 4 already emits `CommentAdded`; you add the email and a listener that sends it from a queue job, so a slow mail server never slows the page down, and a failed send is retried. ## The email Create `app/mailers/comment.go`, in package `mailers` (where `make:auth` put the account emails): ```go import ( "context" "anetos.dev/anetos/mailer" "tracker/views" ) ``` (Copied from [`examples/tutorial/app/mailers/comment.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/mailers/comment.go), region `imports`.) ```go // NewComment tells an issue's author that someone commented on it. type NewComment struct { Name, Email string // the author's Issue string // the issue's title By, Body string // the comment's author and text URL string // the issue's page } // Build implements mailer.Mailable. func (m NewComment) Build(context.Context) (*mailer.Message, error) { return &mailer.Message{ To: []mailer.Address{{Address: m.Email}}, Subject: "New comment on " + m.Issue, HTML: views.NewCommentMail(m.Name, m.Issue, m.By, m.Body, m.URL), }, nil } ``` (Copied from [`examples/tutorial/app/mailers/comment.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/mailers/comment.go), region `mailable`.) And its HTML, `views/comment_mail.templ`, in the frame of the account emails (`authMail`, from `make:auth`): ```templ // NewCommentMail is the email about a new comment. templ NewCommentMail(name, issue, by, body, url string) { @authMail() {

Hello { name },

{ by } commented on “{ issue }”:

{ body }

See the issue

} } ``` (Copied from [`examples/tutorial/views/comment_mail.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/comment_mail.templ), region `mail`.) ## The listener Create `app/listeners/comments.go`, a new package, `listeners`: ```go import ( "context" "errors" "fmt" "anetos.dev/anetos/db" "anetos.dev/anetos/mailer" "tracker/app/mailers" "tracker/app/models" ) ``` (Copied from [`examples/tutorial/app/listeners/comments.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/listeners/comments.go), region `imports`.) ```go // EmailAuthor emails an issue's author about a new comment, unless they // wrote it. It runs as a queue job (events.OnQueued), after the comment // is committed: retried if the mail server fails. func EmailAuthor(ctx context.Context, e models.CommentAdded) error { comment, err := db.Query[models.Comment](ctx).With(models.CommentRels.Author).Find(e.CommentID) if errors.Is(err, db.ErrNotFound) { return nil // deleted since } if err != nil { return err } issue, err := db.Query[models.Issue](ctx).With(models.IssueRels.Author).Find(comment.IssueID) if err != nil { return err } if issue.AuthorID == comment.AuthorID { return nil } url, err := mailer.URL(ctx, fmt.Sprintf("/issues/%d", issue.ID)) // on APP_URL if err != nil { return err } return mailer.Send(ctx, mailers.NewComment{ Name: issue.Author.Name, Email: issue.Author.Email, Issue: issue.Title, By: comment.Author.Name, Body: comment.Body, URL: url, }) } ``` (Copied from [`examples/tutorial/app/listeners/comments.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/app/listeners/comments.go), region `listener`.) The listener reads the comment again rather than trusting the event's contents: it runs later, maybe after a retry. `mailer.URL` makes the link absolute, on `APP_URL`, since an email leaves the app. Add it in `main.go`: import `"tracker/app/listeners"`, and where `setup` sets up the events, replace the `events.New` lines with: ```go bus, err := events.New(app) if err != nil { return nil, err } // The author's email about a new comment, sent by a queue worker. if err := events.OnQueued(bus, listeners.EmailAuthor); err != nil { return nil, err } ``` (Copied from [`examples/tutorial/main.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/main.go), region `listeners`.) `events.OnQueued` turns each `CommentAdded` into a job, once the comment's transaction commits. The queue's workers run it: in development they run in the app (`QUEUE_DRIVER=database` in `.env`); in production, in the same binary or in processes of their own (`run --only=worker`). ## Try it Register a second user in another browser (or a private window), comment on the first user's issue, and look in the `anetos dev` terminal: the email is in the log, a moment later. ## Test it `notify_test.go`, in package `main`: ```go import ( "net/url" "testing" "anetos.dev/anetos/anetostest" "tracker/app/mailers" "tracker/app/models" "tracker/database/factories" ) ``` (Copied from [`examples/tutorial/notify_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/notify_test.go), region `imports`.) ```go // The author gets an email when someone else comments. In tests the // queue runs jobs at once, and emails are kept, not sent. func TestEmailAuthor(t *testing.T) { app := anetostest.New(t, setup) ada := anetostest.Create(app, factories.Users) grace := anetostest.Create(app, factories.Users) anetostest.Create(app, factories.Issues.With(func(i *models.Issue) { i.Title, i.AuthorID = "Login fails", ada.ID })) anetostest.ActingAs(app, &ada) app.PostForm("/issues/1/comments", url.Values{"body": {"More details."}}).AssertRedirect("/issues/1") anetostest.AssertMailNotSent(app, func(m mailers.NewComment) bool { return true }) // her own anetostest.ActingAs(app, &grace) app.PostForm("/issues/1/comments", url.Values{"body": {"Same here."}}).AssertRedirect("/issues/1") anetostest.AssertMailSent(app, func(m mailers.NewComment) bool { return m.Email == ada.Email && m.By == grace.Name && m.URL == "http://example.test/issues/1" }) } ``` (Copied from [`examples/tutorial/notify_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/notify_test.go), region `test`.) The [events](https://docs.anetos.dev/guides/events/index.md), [queues](https://docs.anetos.dev/guides/queues/index.md) and [mail](https://docs.anetos.dev/guides/mail/index.md) guides go further: delays, retries, SMTP and Postmark. Next: [7. Deploy](https://docs.anetos.dev/getting-started/tutorial/07-deploy.md). # 7. Deploy Source: https://docs.anetos.dev/getting-started/tutorial/07-deploy/ You build the app for production: one binary with everything in it, or a container image. ## Build it ```sh $ go tool anetos build built bin/tracker (linux/amd64, 19.4 MB) ``` The binary holds the migrations, the views and the files of `public/` and `locales/`. It needs its settings, from the environment: `deploy/production.env.example` lists them. Try it locally with production settings and a database of its own: ```sh export APP_ENV=production APP_DEBUG=false APP_KEY=$(go tool anetos key:generate --show | cut -d= -f2-) export DB_NAME=/tmp/tracker.db STORAGE_ROOT=/tmp/tracker-files HTTP_ADDR=:8081 ./bin/tracker migrate ./bin/tracker run ``` `APP_DEBUG=false` overrides `.env`, which the app still reads in the project's folder. Production cookies are HTTPS-only, so logging in needs HTTPS: put a proxy in front, as the deploy guide shows. ## Or as an image The project has a `Dockerfile`: ```sh docker build -t tracker . docker run --rm --env-file production.env tracker migrate docker run -d -p 8080:8080 --env-file production.env -v tracker-data:/data tracker ``` `production.env` is your copy of `deploy/production.env.example`, filled in; keep it out of git. With SQLite, the database is on the `/data` volume. ## Where to run it [Deploy](https://docs.anetos.dev/guides/deployment/index.md) covers a server with systemd, Docker and Docker Compose, Fly.io and Render, HTTPS, and running the queue's workers apart from the web server as traffic grows. ## What's next You've built an app with accounts, pages, forms, htmx, search, events, a queue and email, with its tests. From here: - [`examples/tracker`](https://github.com/anetos-dev/anetos/tree/main/examples/tracker) grows this app into a team's tool: projects with roles, labels, files, history, a daily digest, an admin and a JSON API. - The [guides](https://docs.anetos.dev/guides/index.md) take one feature at a time. - The [concepts](https://docs.anetos.dev/concepts/index.md) explain how Anetos works underneath. # Your first API Source: https://docs.anetos.dev/getting-started/your-first-api/ - [Tutorial: build an API](https://docs.anetos.dev/getting-started/build-an-api/index.md): Build a JSON API for bookmarks: people sign up, get a token, and save links that only they see; a token made for a script can read them but not change them; and the API describes itself in OpenAPI for the apps that call it. About thirty minutes. The finished project is [`examples/bookmarks`](https://github.com/anetos-dev/anetos/tree/main/examples/bookmarks). # Tutorial: build an API Source: https://docs.anetos.dev/getting-started/build-an-api/ Build a JSON API for bookmarks: people sign up, get a token, and save links that only they see; a token made for a script can read them but not change them; and the API describes itself in OpenAPI for the apps that call it. About thirty minutes. The finished project is [`examples/bookmarks`](https://github.com/anetos-dev/anetos/tree/main/examples/bookmarks). You need [Go and the `anetos` tool](https://docs.anetos.dev/getting-started/install/index.md). The [issue tracker tutorial](https://docs.anetos.dev/getting-started/tutorial/index.md) builds an app with pages; this one has none. ## 1. Create the project ```sh anetos new bookmarks --stack=api cd bookmarks go run . migrate go tool anetos dev ``` `--stack=api` writes an app that serves JSON only: no views, sessions or CSRF protection, every error as JSON problem details, the routes under `/api/v1` in `routes/api.go`. In another terminal: ```sh curl http://localhost:8080/api/v1 ``` ```json {"name":"bookmarks","message":"Welcome to the Bookmarks API."} ``` The project also has `openapi.json`, the API's description, which the app serves at `/api/v1/openapi.json`. It grows with each step. [Create a project](https://docs.anetos.dev/getting-started/create-a-project/index.md#an-api-instead) lists the files. ## 2. Accounts and tokens ```sh go tool anetos make:auth go run . migrate ``` `make:auth` writes registration, login, logout, email verification, password reset, two-factor authentication and token management, as JSON endpoints that log in with API tokens. Sign up: ```sh curl -X POST http://localhost:8080/api/v1/register \ -H 'Content-Type: application/json' \ -d '{"name":"Ada","email":"ada@example.com","password":"correct horse","password_confirmation":"correct horse"}' ``` The answer has a `token`. Send it with every request: ```sh curl http://localhost:8080/api/v1/me -H 'Authorization: Bearer 1|q7Xw…' ``` The emails (verification, password reset) go to the log in development, and link to your client app at `AUTH_CLIENT_URL`. [Add accounts to an API](https://docs.anetos.dev/guides/api-accounts/index.md) lists every endpoint. ## 3. Bookmarks ```sh go tool anetos make:crud Bookmark url:string title:string notes:text:optional archived:bool go run . migrate ``` `make:crud` writes the model and its migration, five endpoints in `app/handlers/bookmarks.go` (`GET` and `POST /api/v1/bookmarks`, `GET`, `PUT` and `DELETE /api/v1/bookmarks/{id}`), their routes in `routes/bookmarks.go`, and a test. The list is paged, sorted with `?sort=` and filtered by field (`?archived=true`); each bookmark is answered as `BookmarkResponse`, a struct of the handlers' file, never the model. [Add pages for a model](https://docs.anetos.dev/getting-started/crud/index.md#in-an-api-project) has the details. The endpoints are open to every client, and every bookmark is everyone's. The next steps change that. ## 4. Each user's bookmarks A bookmark belongs to the user who saved it: it needs a `user_id` column. Undo the bookmarks' migration (`go run . migrate:rollback` drops the table, with any bookmark you saved), and add the column to it, after `t.ID()`: ```go t.ForeignID("user_id").Constrained().CascadeOnDelete() // whose bookmark ``` (Copied from [`examples/bookmarks/database/migrations/2026_10_08_140750_create_bookmarks_table.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/database/migrations/2026_10_08_140750_create_bookmarks_table.go), region `user-id`.) and the field to the model, `app/models/bookmark.go`: ```go // Bookmark is a row of the bookmarks table (anetos make:crud). `go tool anetos // gen` writes its typed columns (BookmarkCols) to models_gen.go. type Bookmark struct { db.Model // id, created_at, updated_at UserID int64 `db:"user_id"` // whose bookmark URL string `db:"url"` Title string `db:"title"` Notes string `db:"notes"` Archived bool `db:"archived"` } ``` (Copied from [`examples/bookmarks/app/models/bookmark.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/app/models/bookmark.go), region `model`.) Then `go tool anetos generate` (the typed column `BookmarkCols.UserID`) and `go run . migrate` again. In `app/handlers/bookmarks.go`, two helpers (import `anetos.dev/anetos/auth`): the logged-in user, and a bookmark of theirs: ```go // owner is the logged-in user's ID: a request sees their bookmarks only. func owner(c *web.Ctx) (int64, error) { u, err := auth.Current[*models.User](c) if err != nil { return 0, err } return u.ID, nil } // find returns the user's bookmark id. Another user's is a 404, like // one that doesn't exist (db.ErrNotFound): it isn't theirs to know of. func find(c *web.Ctx, id int64) (models.Bookmark, error) { userID, err := owner(c) if err != nil { return models.Bookmark{}, err } return db.Query[models.Bookmark](c).Where(models.BookmarkCols.UserID.Eq(userID)).Find(id) } ``` (Copied from [`examples/bookmarks/app/handlers/bookmarks.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/app/handlers/bookmarks.go), region `owner`.) `Index` lists the user's bookmarks only; its query starts with: ```go userID, err := owner(c) if err != nil { return db.Page[BookmarkResponse]{}, err } q := db.Query[models.Bookmark](c).Where(models.BookmarkCols.UserID.Eq(userID)) ``` (Copied from [`examples/bookmarks/app/handlers/bookmarks.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/app/handlers/bookmarks.go), region `index`.) `Create` gives the new bookmark its owner: ```go userID, err := owner(c) if err != nil { return BookmarkResponse{}, err } row := models.Bookmark{UserID: userID} ``` (Copied from [`examples/bookmarks/app/handlers/bookmarks.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/app/handlers/bookmarks.go), region `create`.) and `Show`, `Update` and `Delete` load the row with `find(c, in.ID)` in place of `db.Find[models.Bookmark](c, in.ID)`. While you're in the file, check that links are links: the input's `url` field gets the `url` rule, `validate:"required|url|max:255"`. Last, only logged-in clients may reach the bookmarks. Remove the `Bookmarks(api)` line from `routes/api.go`, and call it from `routes/auth.go`, in the group whose routes need a token: ```go Bookmarks(loggedIn) // the user's bookmarks, with their token ``` (Copied from [`examples/bookmarks/routes/auth.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/routes/auth.go), region `logged-in`.) A request without a token now gets 401, and Ada's bookmarks are hers alone: Bob gets a 404 for them, as for a bookmark that doesn't exist, so he can't even learn which IDs are taken. ## 5. An action of your own Archiving a bookmark is one request with nothing to answer. The handler returns `web.Empty`, which answers 204: ```go // Archive marks a bookmark archived: 204, nothing to answer. func (Bookmarks) Archive(c *web.Ctx, in BookmarkID) (web.Empty, error) { row, err := find(c, in.ID) if err != nil { return web.Empty{}, err } row.Archived = true return web.Empty{}, db.Update(c, &row) } ``` (Copied from [`examples/bookmarks/app/handlers/bookmarks.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/app/handlers/bookmarks.go), region `archive`.) Route it in `routes/bookmarks.go`, after the other routes: ```go // illustrative r.Post("/bookmarks/{id}/archive", web.H(h.Archive)).Name("bookmarks.archive") ``` `web.H` binds `{id}` into `BookmarkID` (a bad one is a 400). A handler's signature and its route say what the request takes and answers, which is what the API's description is made from. ## 6. Tokens for scripts A login's token may do everything (its ability is `*`). A script that only backs the bookmarks up shouldn't be able to delete them: give it a token that can only read. Require abilities on the routes, `routes/bookmarks.go` becoming (with `anetos.dev/anetos/auth` among its imports): ```go // Bookmarks adds the bookmarks API: routes/auth.go calls it with its me // group, so every request has a token. Reading needs the token's // bookmarks:read ability, changing bookmarks bookmarks:write; a login's // token has every ability. func Bookmarks(r *web.Router) { var h handlers.Bookmarks read := r.With(auth.RequireAbilities("bookmarks:read")) write := r.With(auth.RequireAbilities("bookmarks:write")) read.Get("/bookmarks", web.H(h.Index)).Name("bookmarks.index") write.Post("/bookmarks", web.H(h.Create)).Name("bookmarks.create").Status(http.StatusCreated) read.Get("/bookmarks/{id}", web.H(h.Show)).Name("bookmarks.show") write.Put("/bookmarks/{id}", web.H(h.Update)).Name("bookmarks.update") write.Delete("/bookmarks/{id}", web.H(h.Delete)).Name("bookmarks.delete") write.Post("/bookmarks/{id}/archive", web.H(h.Archive)).Name("bookmarks.archive") } ``` (Copied from [`examples/bookmarks/routes/bookmarks.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/routes/bookmarks.go), region `routes`.) The user makes the script's token with their password: ```sh curl -X POST http://localhost:8080/api/v1/tokens -H 'Authorization: Bearer 1|q7Xw…' \ -H 'Content-Type: application/json' \ -d '{"name":"backup","abilities":["bookmarks:read"],"password":"correct horse"}' ``` With that token, `GET /api/v1/bookmarks` works, and `POST`, `PUT`, `DELETE` and the archive answer 403. ## 7. Test it `make:crud`'s test, `bookmarks_test.go`, calls the endpoints without a token, and saves `"Example url"`, which the `url` rule now refuses. Replace its `TestBookmarks` with one that signs up first (`auth_test.go`'s `authRegister` signs Ada up and sends her token with the requests that follow): ```go // TestBookmarks creates, lists, shows, replaces, archives and deletes a // bookmark through the API, with a login's token (auth_test.go's // authRegister). func TestBookmarks(t *testing.T) { app, _ := authRegister(t) app.GetJSON("/api/v1/bookmarks").AssertOK().AssertJSONPath("total", 0).AssertJSONPath("data", []any{}) app.PostJSON("/api/v1/bookmarks", map[string]any{}).AssertValidationErrors("url", "title") app.PostJSON("/api/v1/bookmarks", map[string]any{"url": "not a link", "title": "x"}).AssertValidationErrors("url") body := map[string]any{"url": "https://go.dev/doc/effective_go", "title": "Effective Go", "notes": "Read again"} var created handlers.BookmarkResponse res := app.PostJSON("/api/v1/bookmarks", body).AssertCreated().JSON(&created) path := fmt.Sprintf("/api/v1/bookmarks/%d", created.ID) res.AssertHeader("Location", path).AssertJSONPath("title", "Effective Go").AssertJSONPath("archived", false) app.GetJSON(path).AssertOK().AssertJSONPath("id", created.ID) app.GetJSON("/api/v1/bookmarks?sort=title").AssertOK().AssertJSONPath("total", 1).AssertJSONPath("data.0.id", created.ID) app.GetJSON("/api/v1/bookmarks?sort=nonsense").AssertValidationErrors("sort") app.PutJSON(path, map[string]any{"url": "https://go.dev/doc/effective_go", "title": "Effective Go, again"}). AssertOK().AssertJSONPath("title", "Effective Go, again").AssertJSONPath("notes", "") // Archiving answers 204; the list filters on it. app.PostJSON(path+"/archive", nil).AssertNoContent() app.GetJSON("/api/v1/bookmarks?archived=true").AssertOK().AssertJSONPath("total", 1) app.GetJSON("/api/v1/bookmarks?archived=false").AssertOK().AssertJSONPath("total", 0) app.DeleteJSON(path).AssertNoContent() app.GetJSON(path).AssertNotFound() app.DeleteJSON(path).AssertStatus(http.StatusNotFound) } ``` (Copied from [`examples/bookmarks/bookmarks_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/bookmarks_test.go), region `test-crud`.) Add a test that one user doesn't see another's bookmarks: ```go // A user sees their own bookmarks only: another's is a 404. func TestBookmarksAreTheUsers(t *testing.T) { app, _ := authRegister(t) var ada handlers.BookmarkResponse app.PostJSON("/api/v1/bookmarks", map[string]any{"url": "https://ada.example.com", "title": "Ada's"}). AssertCreated().JSON(&ada) var bob handlers.LoginResponse app.PostJSON("/api/v1/register", map[string]any{ "name": "Bob", "email": "bob@example.com", "password": "correct horse", "password_confirmation": "correct horse", }).AssertCreated().JSON(&bob) app.WithHeader("Authorization", "Bearer "+bob.Token) app.GetJSON("/api/v1/bookmarks").AssertOK().AssertJSONPath("total", 0) path := fmt.Sprintf("/api/v1/bookmarks/%d", ada.ID) app.GetJSON(path).AssertNotFound() app.PutJSON(path, map[string]any{"url": "https://bob.example.com", "title": "Bob's"}).AssertNotFound() app.PostJSON(path+"/archive", nil).AssertNotFound() app.DeleteJSON(path).AssertNotFound() } ``` (Copied from [`examples/bookmarks/bookmarks_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/bookmarks_test.go), region `test-owner`.) and that a reading token can't change them: ```go // A token made for reading reads, and can't change bookmarks: 403. // Without a token, 401. func TestBookmarkAbilities(t *testing.T) { app, _ := authRegister(t) var mine handlers.BookmarkResponse app.PostJSON("/api/v1/bookmarks", map[string]any{"url": "https://go.dev", "title": "Go"}).AssertCreated().JSON(&mine) path := fmt.Sprintf("/api/v1/bookmarks/%d", mine.ID) var reader handlers.NewTokenResponse app.PostJSON("/api/v1/tokens", map[string]any{ "name": "reader", "abilities": []string{"bookmarks:read"}, "password": "correct horse", }).AssertCreated().JSON(&reader) app.WithHeader("Authorization", "Bearer "+reader.Token) app.GetJSON("/api/v1/bookmarks").AssertOK() app.GetJSON(path).AssertOK() app.PostJSON("/api/v1/bookmarks", map[string]any{"url": "https://go.dev", "title": "Go"}).AssertForbidden() app.PutJSON(path, map[string]any{"url": "https://go.dev", "title": "Go!"}).AssertForbidden() app.PostJSON(path+"/archive", nil).AssertForbidden() app.DeleteJSON(path).AssertForbidden() app.WithHeader("Authorization", "") app.GetJSON("/api/v1/bookmarks").AssertStatus(http.StatusUnauthorized) } ``` (Copied from [`examples/bookmarks/bookmarks_test.go`](https://github.com/anetos-dev/anetos/blob/main/examples/bookmarks/bookmarks_test.go), region `test-abilities`.) Run them: ```sh go test ./... ``` One test fails until the next step: `TestOpenAPI`, which checks that `openapi.json` describes the routes as they are now. ## 8. The API's description ```sh go run . openapi ``` `openapi.json` now has the archive operation, the `url` field's `format: uri`, and on every bookmark operation the token it needs: ```json "security": [ { "bearer": [ "bookmarks:write" ] } ] ``` with the 401 and 403 those routes may answer. Commit it with the code: its diff shows reviewers how a change alters the API, and client developers load it (from the file, or from `/api/v1/openapi.json`) into an OpenAPI viewer or a client generator. [Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md) explains what goes in it. ## 9. Ship it - **Browsers on other origins** (a front end at `https://app.example.com`) may call the API once `HTTP_CORS_ORIGINS` lists them. - **The client app's address**, `AUTH_CLIENT_URL`, is where emails link to; production refuses to start without it. - **Build and deploy** as any Anetos app: `go tool anetos build`, the `Dockerfile` or the systemd unit in `deploy/` ([Deploy](https://docs.anetos.dev/guides/deployment/index.md)). ## Next steps - [Add accounts to an API](https://docs.anetos.dev/guides/api-accounts/index.md): every account endpoint, two-factor authentication, throttling. - [Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md) and its [reference](https://docs.anetos.dev/reference/openapi/index.md). - [Roles and permissions](https://docs.anetos.dev/guides/roles-and-permissions/index.md): what users may do, with tokens' abilities as a ceiling. - [Rate limiting](https://docs.anetos.dev/guides/rate-limiting/index.md). # Guides Source: https://docs.anetos.dev/guides/ How to do one thing, step by step. ## Basics - [Routing](https://docs.anetos.dev/guides/routing/index.md): Map URLs to handlers, group routes under shared prefixes and middleware, name them, and generate URLs from names. - [Handlers and requests](https://docs.anetos.dev/guides/handlers/index.md): Write handlers that receive typed input, return typed output, and turn errors into proper HTTP responses. - [Render HTML with templ](https://docs.anetos.dev/guides/views/index.md): Write pages as [templ](https://templ.guide) components (typed, compiled, escaped by default), share a layout, link to routes by name, serve static files with cache-busting URLs, and add interactivity with the bundled htmx. - [Style your app](https://docs.anetos.dev/guides/styling/index.md): A new project looks finished from the first page. Its pages are made of components in `views/ui`, a package of your app, styled by one stylesheet, `public/static/app.css`. Change the colors, change a component, add your own, start with Pico, Bootstrap or Bulma, or start without styles. - [Handle HTML forms](https://docs.anetos.dev/guides/forms/index.md): Protect forms against cross-site request forgery, send PUT and DELETE from plain HTML, and show validation errors next to the fields with the submitted values kept. - [Validation](https://docs.anetos.dev/guides/validation/index.md): Declare rules on an input struct, get one clear message per field, and add checks of your own. - [Sessions and flash messages](https://docs.anetos.dev/guides/sessions/index.md): Remember things about a visitor between requests, and show a message once after a redirect. - [Configure your application](https://docs.anetos.dev/guides/configuration/index.md): Read settings from `.env` files and the environment into typed Go structs, with defaults, required keys and validation. - [Commands](https://docs.anetos.dev/guides/commands/index.md): Your application builds to one binary. Its first argument picks what it does: run the app (the default), run only some process types, migrate the database, list the routes, or run commands of your own. - [Test your app](https://docs.anetos.dev/guides/testing/index.md): Boot the app in a test, send it requests like a browser or an API client, and check the responses, the session, the database, the jobs, events and email the app sent, and its files, with `anetostest`; freeze or move the app's clock. - [Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md): Package `web/openapi` writes an OpenAPI 3.1 document of your API from its routes and its typed handlers: what each operation reads and answers, which credentials it needs and how it fails. You commit the document (`openapi.json`) beside the code, a test fails when it's out of date, and the app serves it for clients and tools. ## CSS frameworks - [The starter theme](https://docs.anetos.dev/guides/starter-theme/index.md): The starter theme (`--css=anetos`), which `anetos new` writes unless you pick a CSS framework: Anetos's own stylesheet, about 270 lines of plain CSS, with no framework and no build step. - [Start without styles](https://docs.anetos.dev/guides/plain-html/index.md): `--css=none`: the same components, writing plain HTML without classes, and an `app.css` that holds only a comment. For your own CSS, or a CSS framework anetos doesn't cover. - [Style with Pico](https://docs.anetos.dev/guides/pico/index.md): `--css=pico`: [Pico CSS](https://picocss.com) 2.1.1, a framework that styles plain HTML, so its components write few classes. No build step and no JavaScript. - [Style with Bootstrap](https://docs.anetos.dev/guides/bootstrap/index.md): `--css=bootstrap`: [Bootstrap](https://getbootstrap.com) 5.3.8's classes, its stylesheet and its JavaScript bundle, as released. No build step. - [Style with Bulma](https://docs.anetos.dev/guides/bulma/index.md): `--css=bulma`: [Bulma](https://bulma.io) 1.0.4's classes and its stylesheet, as released, with a small script for the menu button. No build step. - [Style with Tailwind CSS](https://docs.anetos.dev/guides/tailwind/index.md): `--css=tailwind`: components with [Tailwind CSS](https://tailwindcss.com) 4.3.3's utility classes, and a stylesheet that Tailwind compiles from them. `anetos dev` and `anetos build` run Tailwind's standalone CLI for you: no Node.js. ## Data - [Connect to a database](https://docs.anetos.dev/guides/database/index.md): Open your app's database from configuration and use it from handlers, jobs and background tasks. - [Define models and save data](https://docs.anetos.dev/guides/models/index.md): Map structs to tables, and create, update and delete rows with timestamps, soft deletes and hooks. - [Migrations](https://docs.anetos.dev/guides/migrations/index.md): Create and change your database tables with versioned migrations that are compiled into your app and run with one command. - [Query data](https://docs.anetos.dev/guides/queries/index.md): Find rows with typed conditions, sort and paginate them, aggregate, and update or delete in bulk. - [Relations and eager loading](https://docs.anetos.dev/guides/relations/index.md): Declare how models relate (a post belongs to an author, an author has many posts, posts have many tags), load related rows in one query per relation, and filter rows by what they relate to. - [Transactions](https://docs.anetos.dev/guides/transactions/index.md): Make several writes succeed or fail together, and run follow-up work only after they are saved. - [Raw SQL](https://docs.anetos.dev/guides/raw-sql/index.md): Write SQL yourself and scan the results into structs, when the query builder is in the way. - [Seed the database](https://docs.anetos.dev/guides/seeders/index.md): Fill a database with sample data for development, or with the reference data every environment needs. - [Generate typed columns](https://docs.anetos.dev/guides/code-generation/index.md): Let `anetos generate` declare a typed column for every field of your models, and a handle for every relation, so queries say `PostCols.Title.Like("%go%")` and `With(PostRels.Author)`, and the compiler checks the column name, the value's type and the relation's model. ## Accounts and security - [Add accounts with make:auth](https://docs.anetos.dev/guides/accounts/index.md): Give your app user accounts in one command: registration, login with "remember me" and throttling, login with Google and GitHub, two-factor authentication, account settings, logout, email verification, password reset and API tokens, with their pages, emails, routes, migration and tests. The code is written into your app, where you change it as you like; the parts that must be right (password hashing, tokens, sessions, throttling, the OAuth flow) stay in the [`auth`](https://docs.anetos.dev/guides/authentication/index.md) and [`social`](https://docs.anetos.dev/guides/social-login/index.md) packages, so fixes reach you with `go get -u`. - [Add accounts to an API](https://docs.anetos.dev/guides/api-accounts/index.md): In a project made with `anetos new --stack=api`, `make:auth` writes accounts that log in with API tokens: registration, login (with two-factor codes for users who turn them on), logout, email verification, password reset and change, and token management, as JSON endpoints under `/api/v1`. There are no sessions or cookies: a client keeps its token and sends it in every request. - [Authentication](https://docs.anetos.dev/guides/authentication/index.md): Let people register, log in (with "remember me"), log out, verify their email address and reset a forgotten password, and give API clients tokens. The complete app is [`examples/auth`](https://github.com/anetos-dev/anetos/tree/main/examples/auth). In an `anetos new` project, `go tool anetos make:auth` writes all of this into your app: see [Add accounts with make:auth](https://docs.anetos.dev/guides/accounts/index.md). This guide is the `auth` package underneath, step by step. - [Social login](https://docs.anetos.dev/guides/social-login/index.md): Let users log in with Google, GitHub, or any OpenID Connect provider (Okta, Auth0, Microsoft Entra ID, Keycloak, GitLab…). The complete app is [`examples/auth`](https://github.com/anetos-dev/anetos/tree/main/examples/auth). - [Two-factor authentication and password confirmation](https://docs.anetos.dev/guides/two-factor/index.md): Ask for more than a password: a code from an authenticator app (Google Authenticator, 1Password, Authy… : TOTP, RFC 6238) after it, with recovery codes for a lost phone; and the password again before sensitive pages. Package `auth` does the parts that must be right (secrets encrypted, codes used once, throttling); your handlers and pages call it. The complete app is [`examples/auth`](https://github.com/anetos-dev/anetos/tree/main/examples/auth). - [Authorization](https://docs.anetos.dev/guides/authorization/index.md): Decide what a logged-in user may do, with policies the compiler checks. - [Roles and permissions](https://docs.anetos.dev/guides/roles-and-permissions/index.md): Give users roles, globally or in a team, and check what they may do, with package `auth/rbac`. - [Rate limiting](https://docs.anetos.dev/guides/rate-limiting/index.md): Limit how often clients can call your routes, and how often they can attempt actions such as logging in. - [Keep an audit log](https://docs.anetos.dev/guides/audit-log/index.md): Record who created, changed, deleted and restored the rows of your models, field by field, with package `audit`: for data governance, for support, and for "who changed this?". - [Add an admin panel](https://docs.anetos.dev/guides/admin/index.md): Give your staff pages to list, search, filter, create, edit and delete the app's records, and to manage users and roles, with the admin interface of module `anetos.dev/anetos/admin`: no front-end build, permissions from [roles](https://docs.anetos.dev/guides/roles-and-permissions/index.md), and every change in the [audit log](https://docs.anetos.dev/guides/audit-log/index.md) for the models it tracks. ## Background work - [Queues](https://docs.anetos.dev/guides/queues/index.md): Run slow or failure-prone work in the background: charge a card, send an email, build a report. A request dispatches a **job**, a typed struct, and responds at once. **Workers** run the job later, retry it when it fails, and keep a record of the jobs that failed for good. The complete app is [`examples/queue`](https://github.com/anetos-dev/anetos/tree/main/examples/queue). - [Events](https://docs.anetos.dev/guides/events/index.md): Emit an event when something happens, and let listeners react: write an audit log, email a receipt, update a dashboard. The code that places an order then doesn't need to know about all of them. Events are typed Go values, and listeners are functions of their type. The complete app is [`examples/queue`](https://github.com/anetos-dev/anetos/tree/main/examples/queue). - [Scheduling](https://docs.anetos.dev/guides/scheduling/index.md): Run tasks on a schedule: prune old rows every night, send a report every hour, sync with another service every five minutes. The scheduler runs in your app's binary, as a supervised component, so there is no crontab to install and nothing to run every minute. The complete app is [`examples/queue`](https://github.com/anetos-dev/anetos/tree/main/examples/queue). - [Run background tasks](https://docs.anetos.dev/guides/background-tasks/index.md): Run long-lived work, such as a poller, a cache warmer or a heartbeat, as a supervised goroutine that restarts on failure and stops cleanly on deploy. - [Pub/sub listeners](https://docs.anetos.dev/guides/pubsub/index.md): Listen to message streams other services publish, such as `orders.created`, and publish your own, from the same binary as your web app. Where the [queue](https://docs.anetos.dev/guides/queues/index.md) runs an app's own jobs, pub/sub connects services: each subscription gets every message of a topic. The complete app is [`examples/pubsub`](https://github.com/anetos-dev/anetos/tree/main/examples/pubsub), a billing service. ## Mail, files, search and cache - [Send email](https://docs.anetos.dev/guides/mail/index.md): Send email from your app: a receipt, a welcome message, a password reset link. An email is a **mailable**, a type that builds its message from its fields, with an HTML body from a templ component. Send it now, or queue it so a worker sends it with retries. The complete app is [`examples/queue`](https://github.com/anetos-dev/anetos/tree/main/examples/queue), which emails a receipt for each order. - [Store files](https://docs.anetos.dev/guides/storage/index.md): Store files your app receives or makes: uploads, avatars, exports, invoices. Files go on **disks**: a disk is a named place files are kept, which is a local directory by default, or an S3-compatible bucket (Amazon S3, Cloudflare R2, MinIO, …) or a Google Cloud Storage bucket, with the same code. Private files are read through temporary signed URLs. The complete app is [`examples/files`](https://github.com/anetos-dev/anetos/tree/main/examples/files). - [Add full-text search](https://docs.anetos.dev/guides/search/index.md): Give users a search box that finds what they mean: every word they type, in any of the columns you choose, best matches first. Search runs on your database's own full-text engine (PostgreSQL, MySQL/MariaDB or SQLite), with an index built by a migration; there's no search server to run. The complete example is [`examples/forms`](https://github.com/anetos-dev/anetos/tree/main/examples/forms), a notes app with a search box. - [Cache](https://docs.anetos.dev/guides/cache/index.md): Keep the results of slow work for a while, count events for rate limits, and use locks so only one instance of the app does a job at a time. ## Languages and time - [Translations](https://docs.anetos.dev/guides/translations/index.md): Show your app, its validation messages and error pages, and its emails in each user's language. Adding a language is adding a file. - [Numbers, dates and languages](https://docs.anetos.dev/guides/formatting/index.md): Show numbers, prices, dates and times the way each user reads them, in their language and time zone, and add the framework's translations for a language with one command. - [Times and dates](https://docs.anetos.dev/guides/times-and-dates/index.md): Store times without thinking about time zones, keep calendar dates on their day, and choose the zone your app works in. ## Working with AI - [Add AI to your app](https://docs.anetos.dev/guides/ai/index.md): Use a language model in your app: summarize text into a typed struct, answer questions with tools that look up the user's own data, and stream the answer as it's written. Package `ai` does the work around the model: schemas, validation, the tool loop, logging and tests that never call a model. The complete example is [`examples/ai`](https://github.com/anetos-dev/anetos/tree/main/examples/ai), a small support desk API. - [Build an AI assistant](https://docs.anetos.dev/guides/ai-assistant/index.md): Give your users a chat with an agent that answers from your app's data: conversations stored in the database, answers streamed to the page as they're written, slow questions answered in the background, and a usage budget per user. - [Search by meaning](https://docs.anetos.dev/guides/semantic-search/index.md): Find records by what they mean, not only by the words they share: "how much is the team plan" finds the article on plans and billing, which never says "how much". Each record's text is split into chunks, turned into vectors (embeddings) by an embedding model, and stored next to the record; a search embeds the question and returns the nearest records, with the passage that matched. With a full-text index too, the search is *hybrid*: records that its words find rank high as well, so names, codes and rare words aren't missed. An agent can use the search as a tool, to answer from your data (retrieval-augmented generation). ## Production - [Deploy](https://docs.anetos.dev/guides/deployment/index.md): An Anetos app is one binary: the migrations, views, translations and static files are inside it. Deploying it is building it, giving it its settings, running its migrations, and keeping it running. This guide does that on a server with systemd, in a container, and on two hosting platforms (Fly.io, Render). The examples use an app called `blog`. - [Find N+1 queries](https://docs.anetos.dev/guides/n-plus-one/index.md): An N+1 is a query in a loop: one query for a list of posts, then one per post for its author. It works, and it's fast with three posts in development; with three hundred in production, it isn't. Anetos warns when a request (or a job, a listener, a scheduled task) runs the same query many times, and says where, so you can load the rows in one query instead. The complete example is [`examples/database`](https://github.com/anetos-dev/anetos/tree/main/examples/database). - [Secure your app](https://docs.anetos.dev/guides/security/index.md): Anetos starts safe: CSRF protection, encrypted session cookies, parameterized queries, escaped views, security headers, throttled logins. What it can't choose for you are the settings and the deployment: a key, HTTPS, the proxies to trust, the database's connection. This guide is the checklist before you go live, with `doctor` to check what a machine can check. - [Check your app's security](https://docs.anetos.dev/guides/security-check/index.md): `security:check` checks your app against security rules: its code (SQL built from request data, a redirect to a URL from the request, a secret written in a file), its dependencies (known vulnerabilities, with govulncheck), its routes (a form's route without CSRF protection, an admin page anyone can open) and its settings (session cookies sent over plain HTTP, mail in plain text). Each finding says where it is (a line of your code, or a setting), why it matters and how to fix it. The report comes as text, JSON, SARIF (for GitHub's code scanning) or Markdown. ## Extending - [Use plugins](https://docs.anetos.dev/guides/plugins/index.md): Add features to your app with plugins: packages that bring their own routes, database tables, settings, commands, queue jobs, scheduled tasks or event listeners. `anetos add` installs one with one command. The complete app is [`examples/queue`](https://github.com/anetos-dev/anetos/tree/main/examples/queue), which uses the Postmark plugin to stop emailing addresses that bounced. - [Write a plugin](https://docs.anetos.dev/guides/writing-plugins/index.md): Package a feature so any Anetos app can add it with `anetos add`: its routes, tables, settings, commands, queue jobs, scheduled tasks and event listeners. A plugin uses only the framework's public packages, as an app does. The complete plugin is [`plugins/postmark`](https://github.com/anetos-dev/anetos/tree/main/plugins/postmark), which receives Postmark's webhooks and keeps a list of the addresses Postmark stopped sending to. # Basics Source: https://docs.anetos.dev/guides/basics/ - [Routing](https://docs.anetos.dev/guides/routing/index.md): Map URLs to handlers, group routes under shared prefixes and middleware, name them, and generate URLs from names. - [Handlers and requests](https://docs.anetos.dev/guides/handlers/index.md): Write handlers that receive typed input, return typed output, and turn errors into proper HTTP responses. - [Render HTML with templ](https://docs.anetos.dev/guides/views/index.md): Write pages as [templ](https://templ.guide) components (typed, compiled, escaped by default), share a layout, link to routes by name, serve static files with cache-busting URLs, and add interactivity with the bundled htmx. - [Style your app](https://docs.anetos.dev/guides/styling/index.md): A new project looks finished from the first page. Its pages are made of components in `views/ui`, a package of your app, styled by one stylesheet, `public/static/app.css`. Change the colors, change a component, add your own, start with Pico, Bootstrap or Bulma, or start without styles. - [Handle HTML forms](https://docs.anetos.dev/guides/forms/index.md): Protect forms against cross-site request forgery, send PUT and DELETE from plain HTML, and show validation errors next to the fields with the submitted values kept. - [Validation](https://docs.anetos.dev/guides/validation/index.md): Declare rules on an input struct, get one clear message per field, and add checks of your own. - [Sessions and flash messages](https://docs.anetos.dev/guides/sessions/index.md): Remember things about a visitor between requests, and show a message once after a redirect. - [Configure your application](https://docs.anetos.dev/guides/configuration/index.md): Read settings from `.env` files and the environment into typed Go structs, with defaults, required keys and validation. - [Commands](https://docs.anetos.dev/guides/commands/index.md): Your application builds to one binary. Its first argument picks what it does: run the app (the default), run only some process types, migrate the database, list the routes, or run commands of your own. - [Test your app](https://docs.anetos.dev/guides/testing/index.md): Boot the app in a test, send it requests like a browser or an API client, and check the responses, the session, the database, the jobs, events and email the app sent, and its files, with `anetostest`; freeze or move the app's clock. - [Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md): Package `web/openapi` writes an OpenAPI 3.1 document of your API from its routes and its typed handlers: what each operation reads and answers, which credentials it needs and how it fails. You commit the document (`openapi.json`) beside the code, a test fails when it's out of date, and the app serves it for clients and tools. # Routing Source: https://docs.anetos.dev/guides/routing/ Map URLs to handlers, group routes under shared prefixes and middleware, name them, and generate URLs from names. ## Before you start You have an app from `anetos.New()`. See [Configure your application](https://docs.anetos.dev/guides/configuration/index.md). ## Steps ### 1. Create the HTTP server ```go func run() error { app, err := anetos.New() if err != nil { return err } srv, err := web.NewServer(app) // reads HTTP_*, registers the "http" component if err != nil { return err } routes(srv.Router(), &Notes{}) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return app.Run(ctx) } ``` (Copied from [`examples/notes`](https://github.com/anetos-dev/anetos/blob/main/examples/notes/main.go), region `main`.) `web.NewServer` reads the `HTTP_*` settings (address, timeouts, body limit, CORS…), installs the default middleware and adds the server to the app as the `http` component. `app.Run` starts it, and SIGTERM shuts it down gracefully, letting in-flight requests finish. ### 2. Register routes ```go func routes(r *web.Router, notes *Notes) { r.Get("/", func(c *web.Ctx) error { return c.RedirectRoute("notes.index") }).Name("home") api := r.Group("/notes").As("notes.") api.Get("", web.H(notes.List)).Name("index") api.Post("", web.H(notes.Create)).Name("create").Status(http.StatusCreated) api.Get("/{id}", web.H(notes.Show)).Name("show") api.Delete("/{id}", web.H(notes.Delete)).Name("delete") } ``` (Region `routes`.) - `Get`, `Post`, `Put`, `Patch`, `Delete` and `Options` register a handler for one method. `GET` routes also answer `HEAD`. `Handle("", pattern, h)` matches every method. - Handlers are `func(c *web.Ctx) error`, or typed handlers wrapped with `web.H`. See [Handlers and requests](https://docs.anetos.dev/guides/handlers/index.md). ### 3. Use path parameters Patterns use Go's `net/http` syntax: | Pattern | Matches | Read with | |---|---|---| | `/posts/{id}` | `/posts/42` | `c.Param("id")` or a `path:"id"` field | | `/files/{path...}` | `/files/a/b.txt` (the rest of the path) | `c.Param("path")` → `a/b.txt` | | `/about/` | only `/about/`; `/about` is redirected to it | | > **Note:** Unlike raw `http.ServeMux`, a pattern ending in `/` matches > **only** that path, not everything below it. Use a `{name...}` wildcard > for a subtree. ### 4. Group routes `r.Group(prefix, middleware...)` returns a router whose routes share a path prefix and middleware. `As("prefix.")` adds a prefix to their names. Groups nest. Inside a group, `""` and `"/"` both mean the group's own path (`/posts`, without a trailing slash). ```go // illustrative admin := r.Group("/admin", requireAdmin).As("admin.") admin.Get("/users", h.Users).Name("users") // GET /admin/users, name "admin.users" ``` Routes for one host only (an admin at `admin.example.com`) go on `r.Host(host)`, which works like a group. A host's routes win for that host; routes without a host answer every host. Requests match on the host name, whatever their port. The routes' URLs are absolute, with `APP_URL`'s scheme (`https` without one) and the port given to `Host`, if any, since another host can't be reached with a path: ```go // illustrative admin := r.Host("admin.example.com").Group("", requireAdmin) admin.Get("/", h.Dashboard).Name("admin.home") // c.URL("admin.home"): "https://admin.example.com/" ``` ### 5. Name routes and generate URLs Give routes names and build URLs from them, so paths live in one place: ```go // illustrative url, err := c.URL("notes.show", note.ID) // "/notes/7" return c.RedirectRoute("notes.index") // 303 See Other return web.RedirectRoute("notes.show", note.ID), nil // from a typed handler ``` Arguments fill the wildcards in order and are path-escaped; a `url.Values` after them becomes the query string (`c.URL("notes.index", url.Values{"page": {"2"}})` is `"/notes?page=2"`). A wrong name or argument count is an error. `r.MustURL` panics instead, which is useful for fixed links at startup. In views, `web.URL(ctx, name, args...)` builds the same paths from the request's context, in the request's locale, and `web.MustURL(ctx, name, args...)` returns the path alone for a component's argument, panicking on a mistake as `r.MustURL` does: the router answers it with a 500 ([Render HTML with templ](https://docs.anetos.dev/guides/views/index.md#2-write-a-layout)). ### 6. Add middleware Middleware is plain `func(http.Handler) http.Handler`, so any net/http middleware works. | Call | Applies to | |---|---| | `r.UseGlobal(mw...)` | Every request, including 404/405, before routing. Call it before the server starts | | `r.Use(mw...)` | Routes registered on `r` (and groups created from it) **afterwards**. It panics if `r` or any of its groups already has routes, so middleware can't silently miss routes | | `r.Group(prefix, mw...)` | Routes in the group | | `r.With(mw...).Get(...)` | A single route | `web.NewServer` already installs these global middleware, outermost first: `Recover`, `RequestIDs`, `RealIP`, `AccessLog`, `SecureHeaders`, `CORS` (when configured), the request's locale (with [translations](https://docs.anetos.dev/guides/translations/index.md)), `BodyLimit` and `Timeout`. See the [configuration reference](https://docs.anetos.dev/reference/configuration/index.md#http-server). `web.ClientIP(ctx)` returns the client's address `RealIP` found (or the peer's), from the request's context: `web.ClientIP(c)` in a handler. To limit how often clients call a group of routes, add `ratelimit.Middleware` to it: see [Rate limiting](https://docs.anetos.dev/guides/rate-limiting/index.md). `r.Routes()` lists each route's middleware, outermost first, named as Go code names the function that makes it (`web.CSRF`, `auth.Auth.Require`, `routes.adminOnly`), and `r.GlobalMiddleware()` the global ones: `go run . route:list --json` prints them. A middleware that asks for credentials or answers errors of its own can say so to the API's description: it returns its handler through `web.Documented(h, web.MiddlewareDoc{…})`, with a `Name` and a `Detail` too if you like. See [Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md#5-say-what-your-middleware-asks-for). ## How it works The router registers your patterns on a standard `http.ServeMux`, adding the method and making trailing-slash patterns exact. A catch-all route handles everything else: - A path that exists for other methods gets **405 Method Not Allowed** with an `Allow` header listing them (including custom methods such as `PURGE`), and `OPTIONS` gets **204** with `Allow`. - Anything else gets **404**, as JSON problem details or an HTML page (see [Return errors](https://docs.anetos.dev/guides/handlers/index.md#4-return-errors)); always JSON when `web.JSONErrors` is global middleware, as in an API project. > **Coming from another framework?** `r.Get(...).Name("x")` and > `c.URL("x", id)` are named routes, like Django's `name=` with > `reverse()`, Laravel's `->name()` with `route()`, or Rails' `as:` with > its path helpers; `r.Group()` groups routes under a prefix. ## Testing it The router is an `http.Handler`, so `httptest` works directly: ```go // illustrative r := web.NewRouter() routes(r, &Notes{}) rec := httptest.NewRecorder() r.ServeHTTP(rec, httptest.NewRequest("GET", "/notes/1", nil)) ``` ## Common problems | Symptom | Cause | Fix | |---|---|---| | Panic: `Use called after routes were added` | `r.Use` after `r.Get(...)` | Call `Use` first, or use `r.With(mw).Get(...)` | | Panic: `pattern "posts" must start with /` | Missing slash | Write `/posts` | | Panic: `… conflicts with …` (from net/http) | Two routes match the same requests | Remove the duplicate or make one more specific | | `/about` redirects to `/about/` | The route is `/about/` | Register `/about` instead | | Panic: `duplicate route name` | Same name used twice | Names are global; use group prefixes (`As`) | ## Next steps - [Handlers and requests](https://docs.anetos.dev/guides/handlers/index.md): typed handlers, binding, responses, errors - [HTTP request lifecycle](https://docs.anetos.dev/concepts/http-request-lifecycle/index.md) # Handlers and requests Source: https://docs.anetos.dev/guides/handlers/ Write handlers that receive typed input, return typed output, and turn errors into proper HTTP responses. ## Before you start Read [Routing](https://docs.anetos.dev/guides/routing/index.md) to see how handlers are registered. ## Steps ### 1. Describe the input An input struct says where each value comes from: ```go // Note is what the API returns. type Note struct { ID int `json:"id"` Title string `json:"title"` Body string `json:"body"` Tags []string `json:"tags,omitempty"` CreatedAt time.Time `json:"created_at"` } // CreateNote is the input for POST /notes. It binds from JSON or a form, // and the validate rules run before the handler. type CreateNote struct { Title string `json:"title" validate:"required|max:200"` Body string `json:"body" validate:"max:10000"` Tags []string `json:"tags" validate:"max:5|distinct|alpha_dash"` } // Validate runs after the tag rules pass, for checks tags can't express. func (in CreateNote) Validate(context.Context) error { if strings.EqualFold(strings.TrimSpace(in.Title), "untitled") { return validate.Fail("title", "Please give the note a real title.") } return nil } // ListNotes reads its input from the query string. type ListNotes struct { Limit int `query:"limit" validate:"min:0|max:100"` } // NoteID reads the {id} path parameter. type NoteID struct { ID int `path:"id"` } ``` (Copied from [`examples/notes`](https://github.com/anetos-dev/anetos/blob/main/examples/notes/main.go), region `types`.) | Tag | Source | |---|---| | `json:"title"` | JSON body. Also used as the form field name when there's no `form` tag | | `form:"title"` | URL-encoded or multipart form field | | `query:"page"` | Query string (`[]T` collects repeated values) | | `path:"id"` | Path wildcard `{id}` | | `header:"X-Token"` | Request header | | `validate:"required\|max:200"` | Not a source: [validation rules](https://docs.anetos.dev/guides/validation/index.md) checked after binding | Values are converted to the field's type: strings, numbers, booleans, `time.Duration`, `time.Time` (RFC 3339), pointers, and any type with an `UnmarshalText` method. Uploaded files are `*multipart.FileHeader` or `[]*multipart.FileHeader` with a `form` tag. See the [binding reference](https://docs.anetos.dev/reference/binding/index.md). ### 2. Write a typed handler ```go // illustrative func (h *Notes) Create(c *web.Ctx, in CreateNote) (Note, error) { return h.create(in.Title, in.Body), nil } func (h *Notes) Show(c *web.Ctx, in NoteID) (Note, error) { note, ok := h.find(in.ID) if !ok { return Note{}, web.Error(http.StatusNotFound, "note not found") } return note, nil } ``` Register it with `web.H` (H for handler): ```go // illustrative api.Post("", web.H(notes.Create)).Name("create").Status(http.StatusCreated) ``` `web.H` inspects the input type **once, at startup**. It panics then if the type can't be bound or a `validate` tag is wrong (an unknown rule, a bad parameter), so a mistake never waits for the first request. `web.H` has nothing to do with Gin's `gin.H` map: it adapts a typed handler. A map is answered as JSON here too (`map[string]any`), with no special type. ### 3. Choose the response | Return | Response | |---|---| | Any value (struct, slice, map…) | JSON with the route's status: `200`, or what `Status` sets on the route; no body with `204` or `205` | | `web.Empty{}` | `204` without a body (or the route's status, still without a body) | | `web.Created(v)` | `201` with JSON | | `web.JSON(status, v)` | JSON with any status | | `web.NoContent()` or a nil `web.Responder` | `204` | | `web.Redirect(url)` / `web.RedirectRoute(name, args...)` | `303 See Other` | | `web.Text(status, s)` | Plain text | | Your own type with `Respond(c *web.Ctx) error` | Whatever it writes | For an API, return a typed value and put the status on the route: `.Status(http.StatusCreated)` for a route that creates something, `web.Empty` for one with nothing to answer (a deletion). The handler's signature and the route then say what the request answers, which the API's description reads ([Describe an API with OpenAPI](https://docs.anetos.dev/guides/openapi/index.md)); `Status` takes 2xx statuses only, and errors are written as usual. `Status` sets what a `web.H` handler's result answers: a `web.Responder`, a plain handler and a `web.WrapHandler` route write their own status, and ignore it. Use `web.Responder` as the output type when one handler returns different kinds of responses, chosen as it runs: pages' redirects, say. Handlers can also be plain `func(c *web.Ctx) error` and write with `c.JSON`, `c.Text`, `c.HTML`, `c.Blob`, `c.Redirect`, `c.RedirectRoute` or `c.NoContent`. ### 4. Return errors Return an error and let the framework respond: - `web.Error(status, "message")` gives that status with your message, which is shown to the client. - Your own errors can implement `HTTPStatus() int` (`web.StatusCoder`) to pick a status, for example a repository's "not found" error returning 404. The method is `HTTPStatus`, not go-kit's `StatusCode`. - Anything else is a **500**, and its message is **not** shown to clients unless `APP_DEBUG=true`. Error responses are chosen per client: - **API clients** (sending `Accept: application/json`, a JSON body, or `X-Requested-With: XMLHttpRequest`), and every request under `web.JSONErrors` (below), get RFC 9457 problem details: ```json { "type": "about:blank", "title": "Unprocessable Entity", "status": 422, "detail": "The given data was invalid.", "errors": { "title": "The title field is required." }, "request_id": "dikxmvlw5ml243cr" } ``` Binding and validation errors fill the `errors` object, keyed by field name. Your own errors can do the same by implementing `FieldErrors() map[string]string` (`web.FieldErrorer`). - **Browsers** get an HTML error page. With `APP_DEBUG=true` it shows the error chain, the stack trace for panics, and request details (with `Authorization` and `Cookie` headers redacted). A project made with `anetos new` shows its error pages in its own layout: `routes/web.go` passes `views.ErrorPage` (in `views/errors.templ`, yours to change) to the router's `ErrorPages`. It gets a `web.ErrorPage`: the status, its translated title, the error's message for the visitor if any (never a 5xx's internals), the field errors of a 4xx that isn't a form's redirect, and the request ID. With `APP_DEBUG=true`, a 5xx still shows the framework's page with its details. A URL that matches no route runs only the global middleware, so its page has no session: the header shows a guest's links. If the page fails to render (or panics), the framework's is sent and the failure logged. Another app adds it the same way: ```go // illustrative r.ErrorPages(func(_ *web.Ctx, e web.ErrorPage) view.Component { return views.ErrorPage(e) }) ``` An API can't count on its clients asking for JSON: `curl`, `fetch` without headers and many HTTP libraries send `Accept: */*` or nothing, and would get the HTML page. Put `web.JSONErrors` on the API's routes, and their errors are problem details whatever the client sends, never a page or a redirect back to a form: ```go // The API's errors are JSON problem details, whatever the client // accepts, and a guest gets a 401, not the login page. var api handlers.API v1 := r.Group("/api", web.JSONErrors, a.TokenMiddleware, a.Require) // Authorization: Bearer v1.Get("/projects", web.H(api.Projects)).Name("api.projects") v1.Get("/projects/{project}/issues", web.H(api.Issues)).Name("api.issues") v1.Post("/projects/{project}/issues", web.H(api.CreateIssue)).Name("api.issues.create").Status(http.StatusCreated) v1.Get("/projects/{project}/issues/{number}", web.H(api.Issue)).Name("api.issues.show") ``` (Copied from [`examples/tracker/routes/tracker.go`](https://github.com/anetos-dev/anetos/blob/main/examples/tracker/routes/tracker.go), region `api`.) Under it, `c.WantsJSON()` (and `web.WantsJSON(r)` in middleware) is true, so everything that treats API clients differently does so: `auth`'s `Require` answers 401 rather than redirecting to the login page. In debug mode the error's details are in the problem's `debug` member. Put it before the middleware whose answers it should change, such as `Require`: it acts from its place in the chain on. A project made with `anetos new --stack=api` puts it on every request (`r.UseGlobal(web.JSONErrors)` in `routes/api.go`), so a URL that matches no route gets a JSON 404 too, and a panic in middleware a JSON 500. On a group, only that group's routes are affected: a URL under the group's prefix that matches none of them gets the router's usual 404. Panics in handlers are recovered and handled like errors. Every 5xx is logged with the request ID. Two edge cases: - **The response already started.** If a handler writes part of a response and then fails or panics, the error is logged and the connection is aborted, so the client sees a failed response rather than a truncated one that looks complete. - **Cancellation.** If the client went away, nothing is written (logged as 499). If a handler returns `context.Canceled` from some internal context while the client is still waiting, it's a 500. ## How binding works 1. The **body** is decoded: JSON (`Content-Type: application/json` or `+json`) into the whole struct, or a form into `form`/`json`-named fields. Other content types get **415**, and bodies over `HTTP_MAX_BODY` get **413**. 2. Then **query**, **header** and **path** values are applied. Fields with these tags can **never** be set from the body, so a client can't override `{id}` by sending `"ID": 999` in JSON. 3. Conversion failures give **400** with each bad field listed. 4. The `validate` tag rules run. Failures give **422** with a message per field. See [Validation](https://docs.anetos.dev/guides/validation/index.md). 5. If they pass and the input has `Validate(ctx) error`, it runs. `validate.Fail(field, message)` (or a `*validate.Errors`) gives **422**, an `HTTPError` keeps its own status, and any other error is a **500**. > **Coming from another framework?** The input struct plays the role of > a Django form, a Laravel form request, or Rails' strong parameters > and model validations: it > declares the input, its rules (`required|email`) and extra checks, and > is validated before your handler runs. ## Testing it Send requests to the app's router with `anetostest`: ```go // illustrative app := anetostest.New(t, setup) app.PostJSON("/notes", map[string]string{"title": "Hi"}). AssertCreated(). AssertJSONPath("title", "Hi") ``` See [Test your app](https://docs.anetos.dev/guides/testing/index.md#3-test-a-json-api). A router alone works with `httptest` too: `r.ServeHTTP(rec, req)`. ## Common problems | Symptom | Cause | Fix | |---|---|---| | Panic at startup: `input type … must be a struct` | `web.H` handler takes a non-struct | Wrap inputs in a struct | | Panic at startup: `unsupported field type` | e.g. a `map` field with a `query` tag | Use supported types or read `c.Request()` yourself | | Panic at startup: `validate: … unknown rule` | A typo in a `validate` tag, or a custom rule registered after the route | Check the [rules reference](https://docs.anetos.dev/reference/validation-rules/index.md); register custom rules in `init` | | Field stays empty for JSON | The field has a `path`/`query`/`header` tag, or its JSON name differs | Check tags; body can't fill those fields | | `415 Unsupported Media Type` | Missing or wrong `Content-Type` | Send `application/json` or a form content type | | 500 with no details | Production mode hides internal errors | Check the log line with the same `request_id`, or set `APP_DEBUG=true` locally | ## Next steps - [Validation](https://docs.anetos.dev/guides/validation/index.md) - [Binding reference](https://docs.anetos.dev/reference/binding/index.md) - [HTTP request lifecycle](https://docs.anetos.dev/concepts/http-request-lifecycle/index.md) # Render HTML with templ Source: https://docs.anetos.dev/guides/views/ Write pages as [templ](https://templ.guide) components (typed, compiled, escaped by default), share a layout, link to routes by name, serve static files with cache-busting URLs, and add interactivity with the bundled htmx. ## Before you start [Route requests to handlers](https://docs.anetos.dev/guides/routing/index.md). Pages with forms also need [sessions](https://docs.anetos.dev/guides/sessions/index.md). ## Steps ### 1. Add templ to your module ```sh go get -tool github.com/a-h/templ/cmd/templ@latest ``` Components live in `.templ` files; `go tool templ generate` turns each into a `_templ.go` file that you commit. Add `//go:generate go tool templ generate` to one Go file so `go generate ./...` runs it, and `go tool anetos dev` runs it on every change. ### 2. Write a layout A layout is a component that renders its children inside the page shell: ```templ // Layout is the page shell. Pages pass their content as children. templ Layout(title string) { { title } · Notes
Notes
if msg := view.Flash(ctx, "status"); msg != "" {

{ msg }

}
{ children... }
} ``` (Copied from [`examples/forms/layout.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/forms/layout.templ), region `layout`.) `ctx` is available in every component: it is the request's context, so the helpers work inside templates. `web.URL(ctx, name, args...)` builds a route's path; templ accepts its `(string, error)` result in attributes and text, and a wrong route name fails the render with an error page. A component's argument can't take a `(string, error)` pair. There, `web.MustURL(ctx, name, args...)` returns the path alone, and panics on a route name that doesn't exist or arguments the route doesn't take. The router answers the panic with a 500 page, and since rendering is buffered, the visitor never gets half a page: ```templ // illustrative @ui.LinkButton(web.MustURL(ctx, "posts.edit", post.ID), ui.Secondary) { Edit } ``` A project made with `anetos new` has a layout like this one, built from the components of its `views/ui` package (`ui.Header`, `ui.Nav`, `ui.Main`, `ui.Flash`…), which hold the markup and the class names ([Style your app](https://docs.anetos.dev/guides/styling/index.md)). ### 3. Write pages and render them A page wraps its content in the layout: ```templ // illustrative templ PostPage(p Post) { @Layout(p.Title) {

{ p.Title }

{ p.Body }

} } ``` In a project made with `anetos new`, pages call the components of `views/ui` for their structure, and need no class names ([UI components reference](https://docs.anetos.dev/reference/ui/index.md)): ```templ // illustrative templ PostPage(p Post) { @Layout(p.Title) { @ui.PageHeader(p.Title, "") { @ui.LinkButton(web.MustURL(ctx, "posts.edit", p.ID), ui.Secondary) { Edit } } @ui.Card("") { @ui.Multiline(p.Body) } } } ``` Render it from a handler with `c.Render`, or return `web.Render` from a typed handler: ```go // illustrative // PostID binds the {id} path parameter: ID int64 `path:"id"`. func (h Posts) Show(c *web.Ctx, in PostID) (web.Responder, error) { p, err := db.Find[Post](c, in.ID) if err != nil { return nil, err } return web.Render(PostPage(p)), nil } ``` Rendering is buffered: if a component returns an error, the visitor gets an error page, not half a page. ### 4. Serve static files `view.NewAssets` serves the files of one or more file systems and builds their URLs with a content hash: ```go // public holds the files of the public directory. // //go:embed public var public embed.FS // assets serves public/ and the bundled htmx at /assets, with hashed URLs. var assets = mustAssets() func mustAssets() *view.Assets { files, err := fs.Sub(public, "public") if err != nil { panic(err) } a, err := view.NewAssets("/assets", files, htmx.FS) if err != nil { panic(err) } return a } ``` (Copied from [`examples/forms`](https://github.com/anetos-dev/anetos/blob/main/examples/forms/main.go), region `assets`.) `assets.URL("app.css")` is `/assets/app.css?v=3f2a9c01d4`. Requests with the current hash may be cached for a year; a changed file gets a new URL. Mount it with `r.Get("/assets/{path...}", web.WrapHandler(assets))`. `htmx.FS` holds the bundled htmx (version `htmx.Version`) and its server-sent events extension, `htmx-ext-sse.min.js` (`htmx.SSEVersion`), for pages that show updates as they happen: a streamed AI answer (see [Build an AI assistant](https://docs.anetos.dev/guides/ai-assistant/index.md)), or events of your own, sent with `c.EventStream()`. Files that need a fixed URL, such as `robots.txt` or `.well-known/security.txt`, go in `public/` itself, the web root, which the project's `routes/web.go` serves at `/`: ```go // illustrative r.Static("/", public.Files) // public/robots.txt at /robots.txt ``` `r.Static(prefix, fsys)` serves a file system's files below a prefix, for GET and HEAD requests that no route matches: routes win. It never serves directories, Go files, or hidden files other than those in `.well-known/`; a missing file is the app's 404 page. `public.Files` embeds the whole folder (`//go:embed *`), `static/` too, which `/assets/` serves with hashed URLs: link those through `public.Assets`, which browsers cache. A folder of `public/` that holds only hidden files (`.gitkeep`) stops the build: embed needs a file in it. ### 5. Update parts of a page with htmx htmx requests carry `HX-Request`; `c.IsHTMX()` tells the handler to answer with a fragment. Deleting a note in place: ```templ // noteItem is one note. Its delete form works without JavaScript // (POST with _method=DELETE); with htmx it sends DELETE and removes the // item in place. templ noteItem(n Note) {
  • { n.Title }

    { n.Body }

    Edit
    @view.CSRFField(ctx) @view.MethodField("DELETE")
  • } ``` (Copied from [`examples/forms/notes.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/forms/notes.templ), region `delete-button`.) ```go func (Notes) Delete(c *web.Ctx, in NoteID) (web.Responder, error) { n, err := db.Find[Note](c, in.ID) if err != nil { return nil, err } if err := db.Delete(c, &n); err != nil { return nil, err } if c.IsHTMX() { // htmx replaces the note's list item with this empty response. return web.Text(http.StatusOK, ""), nil } c.Session().Flash("status", "Note deleted.") return web.RedirectRoute("notes.index"), nil } ``` (Copied from [`examples/forms`](https://github.com/anetos-dev/anetos/blob/main/examples/forms/main.go), region `delete`.) With `views/ui`, `ui.Form` writes the same form, CSRF token and `_method` field included, and takes the htmx attributes: ```templ // illustrative @ui.Form(web.MustURL(ctx, "notes.delete", n.ID), "DELETE", templ.Attributes{ "hx-delete": web.MustURL(ctx, "notes.delete", n.ID), "hx-target": "closest li", "hx-swap": "outerHTML", "hx-confirm": "Delete this note?", }) { @ui.Button(ui.Danger, nil, ui.Small) { Delete } } ``` The layout puts the CSRF token in `hx-headers`, so every htmx request passes [CSRF protection](https://docs.anetos.dev/guides/forms/index.md). `c.HTMX()` returns the other htmx headers (target, trigger, boosted). For updates the server pushes, `c.EventStream()` starts a response of server-sent events, which the SSE extension swaps into the page: ```go // illustrative stream, err := c.EventStream() // no request or write timeout for this response if err != nil { return err } for status := range updates { if err := stream.Send("status", html.EscapeString(status)); err != nil { return nil // the browser left } } return stream.Send("done", "") ``` ```html
    ``` Close the stream with an event (`sse-close`): browsers reconnect to a stream that ends. `stream.Comment(text)` sends a keep-alive, and `srv.Stopping()` tells a long stream that the server is shutting down. ### 6. Or use html/template `view.Template(t, name, data)` turns an `html/template` template into a component, so `c.Render` works the same way. ## Complete example [`examples/forms`](https://github.com/anetos-dev/anetos/blob/main/examples/forms/main.go) is a notes app with a layout, forms, flash messages, assets and htmx. ## How it works A component is anything with `Render(ctx context.Context, w io.Writer) error`: that is templ's `templ.Component`, so the framework doesn't depend on templ. `c.Render` renders into a pooled buffer with the `*web.Ctx` as context and writes it with `Content-Type: text/html; charset=utf-8`. `c.IsHTMX()` adds `Vary: HX-Request`, so caches keep fragments and full pages apart. > **Coming from another framework?** templ components play the role of > Django templates, Laravel's Blade views and components, or Rails' > views and partials; `{ children... }` is where the content goes. > `web.URL` builds a route's URL, and `assets.URL` gives a static file's > URL without a build step (Vite arrives in v0.6). ## Testing it Render a component to a string with `view.String(ctx, component)`, or request the page with `anetostest` and check it with `AssertSee`, as [`examples/forms`](https://github.com/anetos-dev/anetos/blob/main/examples/forms/main_test.go) does. ## Common problems | Symptom | Cause | Fix | |---|---|---| | `undefined: NotesPage` | The `.templ` file wasn't generated | Run `go generate ./...` (or `go tool templ generate`) | | `web: unknown route name: "…"` when rendering | A typo in a route name | Use the name given with `.Name(…)` | | A 500 page, and a panic with `web: unknown route name` in the log | The same typo, in `web.MustURL` | Use the name given with `.Name(…)`; `go run . route:list` lists them | | `view: no session for this request` | A page uses `view.CSRFField` on a route without the session middleware | Add the middleware to the route's group | | Browsers keep an old CSS file | A URL written by hand, without the hash | Use `assets.URL(name)` | ## Next steps - [Style your app](https://docs.anetos.dev/guides/styling/index.md) with the components of `views/ui` - [Handle HTML forms](https://docs.anetos.dev/guides/forms/index.md) - [Sessions and flash messages](https://docs.anetos.dev/guides/sessions/index.md) - [Views, sessions and forms reference](https://docs.anetos.dev/reference/views/index.md) # Style your app Source: https://docs.anetos.dev/guides/styling/ A new project looks finished from the first page. Its pages are made of components in `views/ui`, a package of your app, styled by one stylesheet, `public/static/app.css`. Change the colors, change a component, add your own, start with Pico, Bootstrap or Bulma, or start without styles. ## Before you start A project made with `anetos new` v0.5 or later. (A project made before v0.5 has no `views/ui` yet: see [below](https://docs.anetos.dev/guides/styling/index.md#projects-made-before-v05).) There is no CDN and no build step, except with Tailwind CSS, whose build `anetos dev` runs for you ([Tailwind CSS](https://docs.anetos.dev/guides/tailwind/index.md)). `anetos dev` reloads the page when you save a file. ## Steps ### 1. Know what's there The look of your app is two parts, which a *CSS framework* writes together: | File | Holds | |---|---| | `views/ui/shell.templ` | The page's shell, which the layout puts together: `Head`, `Header`, `Nav`, `NavLink`, `NavEnd`, `NavItem`, `Main`, `Footer`, `Flash` | | `views/ui/page.templ` | A page's structure: `PageHeader`, `Card`, `Narrow`, `Stack`, `Cluster`, `Empty`… | | `views/ui/form.templ` | Forms: `Form`, `Field`, `Input`, `Textarea`, `Select`, `Checkbox`, `Button`, `LinkButton`, `PostButton`… | | `views/ui/data.templ` | Lists and values: `Table`, `Details`, `Badge`, `Pagination`… | | `views/ui/ui.go` | The types the components take: `Variant` (a button's kind: primary, secondary, danger, ghost), `Size` (small, full width), `Tone` (the meaning a badge's or a message's color gives: success, warning, error…), `Option`, `Pages` | | `views/ui/classes.go` | The framework's classes for each variant, size and tone (not with `none`) | | `public/static/app.css` | The starter theme: light and dark, about 270 lines of plain CSS | Only `views/ui` has class names. The layout (`views/layout.templ`), the home page and the pages of `make:crud` and `make:auth` call the components, and use plain HTML elements, without classes, for the rest: headings, paragraphs, links, table rows. The [UI components reference](https://docs.anetos.dev/reference/ui/index.md) lists every component. > **Note:** `--css` picks only the look. Accounts come from `make:auth`, > which writes its pages with the same components. ### 2. Build pages with the components A page calls the components for its structure. The tutorial's list of issues: ```templ // IssuesPage lists a page of issues, with links to the pages around it. templ IssuesPage(page db.Page[models.Issue]) { @Layout("Issues") { @ui.PageHeader("Issues", "") { @ui.LinkButton(web.MustURL(ctx, "issues.new"), ui.Primary) { New issue } } if page.Total == 0 { @ui.Empty("No issues yet.") } else { @ui.Table() { for _, issue := range page.Data { @statusBadge(issue.Status) { issue.Title } #{ issue.ID } by { issue.Author.Name } } } } if page.LastPage > 1 { @ui.Pagination(ui.PagesOf(ctx, page, "Pages", "", "Newer", "Older")) } } } // statusBadge shows an issue's status: green while it's open. templ statusBadge(status string) { if status == "open" { @ui.Badge(ui.Success) { { status } } } else { @ui.Badge(ui.Neutral) { { status } } } } ``` (Copied from [`examples/tutorial/views/issues.templ`](https://github.com/anetos-dev/anetos/blob/main/examples/tutorial/views/issues.templ), region `list`.) - A component with content takes it as children, in `{ }`: `ui.PageHeader` puts its children (the buttons) beside the title. - A button's variant is one of `ui.Primary` (the default), `ui.Secondary`, `ui.Danger` and `ui.Ghost`; sizes may follow it, `ui.Small` or `ui.FullWidth`: `@ui.LinkButton(url, ui.Secondary, ui.Small)`. - A badge's or a message's tone is `ui.Neutral`, `ui.Success`, `ui.Warning`, `ui.Error` or `ui.Info`. - Components take URLs as strings. `web.MustURL(ctx, name, args...)` is the path of a named route, as `web.URL` is, without the error: a name that doesn't exist is a panic, which the router answers with a 500 page ([Render HTML with templ](https://docs.anetos.dev/guides/views/index.md#2-write-a-layout)). ### 3. Change the colors The colors, radius, fonts and width are variables at the top of `app.css`: ```css /* illustrative */ :root { --primary: #3056d3; /* links and buttons */ --primary-hover: #2445b4; --radius: 8px; --font: system-ui, sans-serif; --width: 64rem; /* the page's width */ } ``` Change `--primary` and `--primary-hover` for your brand. Dark mode follows the visitor's system and has its own values below; set `data-theme="dark"` (or `"light"`) on `` in `views/layout.templ` to force one. ### 4. Change a component The components are yours: change the markup they write, and every page that calls them follows. Keep each component's name and arguments: the pages of `make:crud` and `make:auth` call them as they are. For example, an empty list with a picture, in `views/ui/page.templ` (which then imports your module's `public` package, as `shell.templ` does): ```templ // illustrative // Empty says a list has nothing in it yet. templ Empty(text string) {

    { text }

    } ``` Then add the rules for it at the end of `app.css`. If a component needs another argument, add a new component rather than change the old one's signature; if you do change it, `go build` lists every call to update. ### 5. Add your own component Markup you repeat belongs in a component of its own. Put it in `views/ui`, in a file of your own, so the class names stay in one package: ```templ // illustrative package ui // Stat is a number with its label, as on a dashboard. templ Stat(label, value string) {

    { label }

    { value }

    } ``` A page calls it like the others: `@ui.Stat("Open issues", strconv.Itoa(open))`. The theme has a few classes that no component uses yet, such as `grid` (cards side by side, wrapping), for components of your own. ### 6. Add your own stylesheets Add rules at the end of `app.css`, or put another file in `public/static/` and link it from `ui.Head`, in `views/ui/shell.templ`: ```templ // illustrative templ Head() { } ``` `public.Assets.URL` adds a version to the URL, so browsers fetch the new file after a deploy; the binary embeds `public/`. ### 7. Or start with a CSS framework `anetos new` writes the starter theme. `--css` picks a CSS framework instead: ```sh anetos new blog --css=bootstrap ``` | `--css` | The components' markup | `public/static/` | Guide | |---|---|---|---| | `anetos` (the default) | The starter theme's classes | `app.css`: the starter theme | [The starter theme](https://docs.anetos.dev/guides/starter-theme/index.md) | | `none` | Plain HTML without classes | `app.css`: a comment, for your styles | [Start without styles](https://docs.anetos.dev/guides/plain-html/index.md) | | `pico` | [Pico CSS](https://picocss.com) 2.1.1: mostly plain HTML, which Pico styles | `pico.min.css`, `app.css` (what Pico has no style of) | [Pico](https://docs.anetos.dev/guides/pico/index.md) | | `bootstrap` | [Bootstrap](https://getbootstrap.com) 5.3.8's classes | `bootstrap.min.css`, `bootstrap.bundle.min.js`, `theme.js`, `app.css` | [Bootstrap](https://docs.anetos.dev/guides/bootstrap/index.md) | | `bulma` | [Bulma](https://bulma.io) 1.0.4's classes | `bulma.min.css`, `nav.js`, `app.css` | [Bulma](https://docs.anetos.dev/guides/bulma/index.md) | | `tailwind` | [Tailwind CSS](https://tailwindcss.com) 4.3.3's utility classes | `app.css`, compiled from `views/ui/tailwind.css` | [Tailwind CSS](https://docs.anetos.dev/guides/tailwind/index.md) | The same page, a list that `make:crud` wrote, with each: | `--css` | The products list | |---|---| | `anetos` | ![The products list with the starter theme](https://docs.anetos.dev/images/kits/anetos-list-light.webp) | | `none` | ![The products list without styles](https://docs.anetos.dev/images/kits/none-list-light.webp) | | `pico` | ![The products list with Pico](https://docs.anetos.dev/images/kits/pico-list-light.webp) | | `bootstrap` | ![The products list with Bootstrap](https://docs.anetos.dev/images/kits/bootstrap-list-light.webp) | | `bulma` | ![The products list with Bulma](https://docs.anetos.dev/images/kits/bulma-list-light.webp) | | `tailwind` | ![The products list with Tailwind CSS](https://docs.anetos.dev/images/kits/tailwind-list-light.webp) | Each writes the same components, with the same names and arguments, so the layout, the home page and the pages of `make:crud` and `make:auth` are the same with each: only `views/ui` and `public/static/` differ. Each but `none` follows the visitor's light or dark mode. A framework's files are as released, with its license beside them (`pico.LICENSE.txt`: MIT, as are the others); the binary embeds them, and serves them gzipped to browsers that accept it. Each one's guide shows its pages in light and dark, and how to change its colors and use more of its framework. A CSS framework is `views/ui` with its `public/static/` files, so another restyles every page that calls the components: [step 8](https://docs.anetos.dev/guides/styling/index.md#8-switch-css-frameworks) switches a project's. ### 8. Switch CSS frameworks ```sh go tool anetos css:use bulma ``` switches the project to another CSS framework. It writes its components (`views/ui`) and stylesheets (`public/static/`), removes the old one's files the new one hasn't (`pico.min.css`, `theme.js`, `tailwind.css`…), and runs `templ generate` (and, for Tailwind, `css:build`). The layout, the pages and your own files in `views/ui` stay as they are: the pages call the components, so every one of them takes the new look. `go tool anetos css:use` alone prints the project's. `views/ui/css.json` records the CSS framework: its name, its version, and the SHA-256 of each file it wrote (`anetos new` writes it, and `css:use` updates it). With it, `css:use` knows which of those files you changed since: a component you edited, your colors in `app.css`. It names them and writes nothing: ```text anetos css:use: these files changed since anetos wrote them for the starter theme, or aren't its: public/static/app.css views/ui/page.templ Nothing was written. Run again with --force to replace them (commit first: git then shows what changed), or undo the changes. ``` `--force` replaces them; commit first, and `git diff` shows what to carry over (your colors into the new stylesheet, say). A project without `css.json` (or with one it can't read) needs `--force` too; then it can't tell the old framework's static files from yours, so it lists the files of `public/static/` it left for you to remove. A file of `views/ui` or `public/static` that is a symbolic link counts as changed. Line endings don't: git on Windows may check files out with CRLF. After the switch, `css:use` builds the project: when your own code called something only the old framework's components had (an unexported helper of its `classes.go`), it says so and exits with status 1. It adds `nav.menu` to `locales/en/app.yaml` when missing, and names the other locales to translate it in. `css:use` with the framework the project already has updates its files to your `anetos`'s version (after `go get -tool anetos.dev/anetos/cli/cmd/anetos@latest`: a newer Pico, fixed components), under the same rule. Moving to or from Tailwind, it says which cache line to add to (or remove from) your `Dockerfile`; it doesn't edit it. ### Pages with classes of your own A page can still use classes of its own (`
    `), styled by your rules in `app.css`. They keep working, but they are outside the components: `css:use` restyles the components, not your pages' classes, and says which of your own files in `views/ui` keep theirs. When the markup repeats, make it a component (step 5). ## Projects made before v0.5 A project made before v0.5 has no `views/ui`: its layout and pages carry the starter theme's classes themselves. Nothing changes until you run a generator. The first `make:crud` or `make:auth` writes `views/ui` before the pages that call it, and says so: ```text views/ui has the components for the starter theme, which the new pages call: the project had none (made before v0.5). The layout keeps its markup; the upgrade guide shows how to use them there too. ``` It picks the CSS framework from your stylesheet: `anetos` when `public/static/app.css` has the starter theme's `.card` rule, else `none` (classless markup, for a stylesheet of your own). It doesn't change `app.css`, the layout or your other pages. Both kinds of layout work with the generators: `make:crud` adds its link at the end of the header's nav, and `make:auth` adds `@AccountMenu()` after it, whether the nav is a `