Guía 01
Primeros pasos
En esta página
En esta página vas a crear una aplicación nueva y ejecutarla, añadirle una página y un formulario, probarlos y compilar la aplicación en un único binario.
- Go 1.26 o posterior.
- Node.js 22.12 o posterior, o 20.19 o posterior en Node 20: Vite 8, que compila el frontend, necesita una de esas versiones. El Dockerfile de la aplicación compila con Node 24.
- npm, que viene con Node.
tug newinstala con él los paquetes de la aplicación.
go install github.com/cuonggt/tug/cmd/tug@latest
tug versiongo install pone tug en $(go env GOPATH)/bin, o en $GOBIN si esa
variable está definida, y ese directorio tiene que estar en tu PATH.
tug version muestra la versión instalada, como tug v0.45.0. Las
aplicaciones que crea tug new requieren esa versión.
tug new blog
cd blogtug new crea el directorio blog, que no debe existir todavía o debe
estar vacío, y pone en él una aplicación lista para ejecutarse:
- los archivos del kit de inicio, con un módulo de Go que lleva el nombre
del directorio, salvo que pases
-module github.com/you/blog; - un
.envcon unaAPP_KEYnueva, los 32 bytes aleatorios que cifran la cookie de sesión, yAPP_DEBUG=true, que muestra en una página los detalles de un error del servidor: el error, de dónde vino y la petición; - los módulos de Go, con
go mod tidy, y los paquetes del frontend, connpm install; - el TypeScript de las páginas y las rutas de la aplicación, en
resources/js/tug, para lo que compila la aplicación una vez.
Para una aplicación con cuentas, añade -auth:
tug new -auth blogEso superpone un segundo kit de inicio al primero. Los usuarios se
registran y verifican su correo electrónico; inician sesión, también con un
código de una aplicación de autenticación si lo activan; restablecen una
contraseña olvidada con un enlace que reciben por correo, y cambian en su
configuración su perfil y su foto, su contraseña y la apariencia. Los
usuarios se guardan en SQLite, en app.db, sus fotos en files/, y el
frontend tiene Tailwind y shadcn/ui. Con -postgres o -mysql, los
usuarios se guardan en Postgres o MySQL, que pone en marcha el
compose.yaml de la aplicación: docker compose up -d antes de tug dev.
El código es de la propia aplicación, para cambiarlo según sus
necesidades: los handlers, en auth.go y los archivos junto a él, y el SQL
de la base de datos, en migrations/, que crea sus tablas, y en los
archivos _db.go (Migraciones). Mientras MAIL_HOST
no esté definida, el correo no se envía: con tug dev se guarda en el
buzón de la aplicación, en /_tug/mail, que muestra cada correo como lo
haría un cliente de correo, y la salida de la aplicación tiene una línea
por cada uno, con su enlace. Cuentas explica el resto.
Para páginas renderizadas en el servidor, para los buscadores y para que
las páginas se vean antes de que se ejecuten sus scripts, añade -ssr a
cualquiera de los dos comandos: Node las renderiza, junto a la aplicación.
Renderizado en el servidor explica el resto.
El frontend es React o, con -vue o -svelte, Vue o Svelte: la misma
aplicación, con el mismo código Go, y sus páginas como componentes .vue o
.svelte donde las de React son .tsx; -auth y -ssr funcionan también
con ellos. Esta página sigue con el kit de inicio básico, en React.
tug devtug │ serving http://localhost:8080, built in 268msAbre http://localhost:8080. tug dev ejecuta dos servidores y muestra su
salida junto con la suya, cada línea marcada con tug, app o vite:
- El servidor de desarrollo de Vite,
npm run dev, enlocalhost:5173, que sirve al navegador los módulos del frontend. - La aplicación, el servidor de Go que visita el navegador, compilada
en
.tug/app. Si el puerto 8080 está ocupado,tug devtoma el siguiente libre y lo dice.ADDRoPORT, en.envo en el entorno, eligen uno en su lugar.
La página viene del servidor de Go, y sus scripts de Vite: mientras Vite
está en marcha, el plugin de vite.config.ts escribe su dirección en
public/hot, que el servidor de Go lee cada vez que renderiza una página.
Mientras trabajas:
- Los archivos de Go,
go.modygo.sum, y las plantillas (.html,.tmpl,.gohtml): tug vuelve a compilar la aplicación, escribe los tipos de TypeScript si han cambiado, reinicia la aplicación y recarga el navegador. - Una compilación que falla: sus errores aparecen bajo
app, y la aplicación de la última compilación que funcionó sigue en marcha hasta que guardas un arreglo. - El frontend, en
resources/: Vite envía el cambio al navegador, y un componente de React se actualiza sin recargar la página y conserva su estado. .env: se lee cuando arrancatug dev. Detenlo con Ctrl-C y vuelve a arrancarlo.
La CLI tiene los detalles.
blog/
├── main.go el servidor: sus páginas, rutas y handlers
├── flash.go las claves de los datos flash, a las que tug gen da tipo
├── main_test.go sus pruebas
├── app.html el HTML en el que se renderiza cada página
├── resources/
│ ├── css/app.css
│ └── js/
│ ├── app.tsx el punto de entrada del frontend: encuentra el componente de cada página
│ ├── Layout.tsx lo que rodea a cada página, también el mensaje flash
│ ├── pages/ un componente por página: Home.tsx y Error.tsx
│ └── tug/ escrito por tug gen, no a mano: pages.ts, routes.ts
├── public/ va dentro del binario; Vite compila en public/build
├── vite.config.ts la configuración de Vite, y el plugin con el que trabaja tug dev
├── go.mod, package.json, tsconfig.json
├── .env lo leen los comandos de tug, no la aplicación; no va al repositorio
├── .env.example lo mismo sin la clave, para el repositorio
├── Dockerfile la aplicación como un único binario en una imagen distroless
├── AGENTS.md cómo cambiar la aplicación, para un agente de código
├── CLAUDE.md AGENTS.md, para Claude Code
└── README.mdmain.go tiene dos partes. main lee del entorno la configuración de la
aplicación, APP_KEY con session.KeysFromEnv, y ADDR, PORT y
APP_DEBUG con tug.ConfigFromEnv, y ejecuta la aplicación. newApp monta
la aplicación a partir de ella: las etiquetas de Vite, las páginas de
Inertia con app.html alrededor, las sesiones, la página de error, el
middleware, la ruta que sirve la compilación del frontend bajo /build/ y
las rutas propias de la aplicación. Las pruebas llaman a newApp con una
configuración propia.
AGENTS.md es para un agente de código, que la mayoría de ellos lee al
empezar: cómo añadir una página, un formulario y una prueba, como hace esta
guía a continuación, las reglas que el código no muestra, como no editar
nunca resources/js/tug, y los comandos que comprueban un cambio.
CLAUDE.md lo importa, ya que Claude Code lee AGENTS.md por sí mismo solo
mientras no hay un CLAUDE.md. Son de la aplicación, para cambiarlos a
medida que crece: tug no vuelve a escribirlos.
Una página es un componente de React en resources/js/pages y el código Go
que la renderiza: un struct con sus props, un tug.Page que las une al
componente, un handler y una ruta. La de este ejemplo lista las entradas
del blog. Añade a main.go, con slices y sync entre sus importaciones:
// Post es una entrada del blog.
type Post struct {
ID int `json:"id"`
Title string `json:"title"`
}
// posts guarda las entradas en memoria: se pierden al detener la aplicación.
// Las peticiones se atienden a la vez, así que la lista tiene un bloqueo.
type posts struct {
mu sync.Mutex
list []Post
}
// PostsIndexProps son las props de resources/js/pages/Posts/Index.tsx.
type PostsIndexProps struct {
Posts []Post `json:"posts"`
}
var PostsIndex = tug.Page[PostsIndexProps]("Posts/Index")
func (p *posts) index(c *tug.Ctx) error {
p.mu.Lock()
list := slices.Clone(p.list)
p.mu.Unlock()
return PostsIndex.Render(c, PostsIndexProps{Posts: list})
}y en newApp, debajo de las demás rutas:
p := &posts{}
app.Get("/posts", p.index).Name("posts.index")tug.Page[PostsIndexProps]("Posts/Index") declara el componente
resources/js/pages/Posts/Index.tsx y las props que recibe: Render acepta
un PostsIndexProps y nada más. Las etiquetas json dan a las props el
nombre con el que las recibe el componente, y una lista vacía sale como
[], nunca como null. El nombre de la ruta, posts.index, es como se
refieren a ella tanto el código Go como el frontend sin escribir su URL. Las
entradas se crean en newApp, así que la aplicación de cada prueba empieza
sin ninguna.
Guarda, y tug dev compila la aplicación y vuelve a escribir sus tipos:
tug │ main.go changed
tug │ wrote resources/js/tug/pages.ts, resources/js/tug/routes.ts
tug │ serving http://localhost:8080, built in 844msAhora pages.ts tiene las props de la página, en TypeScript:
export interface Post {
id: number
title: string
}
export interface PostsIndexProps {
posts: Post[]
}El componente va en resources/js/pages/Posts/Index.tsx:
import { Head } from '@inertiajs/react'
import Layout from '../../Layout'
import type { PageProps } from '../../tug/pages'
export default function Index({ posts }: PageProps<'Posts/Index'>) {
return (
<Layout>
<Head title="Posts" />
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</Layout>
)
}PageProps<'Posts/Index'> son las props propias de la página y las que
comparten todas las páginas, como appName, que newApp comparte con
pages.Share. Para enlazarla desde la página de inicio, añade Link a lo
que Home.tsx importa de @inertiajs/react, y esto debajo del saludo:
<Link href={route('posts.index')}>Posts</Link>route() conoce las rutas con nombre de la aplicación. Un nombre que no es
el de ninguna, como route('post.index'), no pasa la comprobación de
tipos, y tampoco una ruta con un comodín llamada sin su valor. Los valores
van por nombre: route('posts.show', { id: 42 }) es /posts/42 para una
ruta /posts/{id}. Vite no comprueba los tipos mientras sirve; tu editor
sí, y también npm run typecheck y tug build.
El formulario añade una entrada. En Go, es un struct con lo que envía el
formulario, con las reglas de cada campo en su etiqueta validate, y un
handler:
// PostInput es lo que envía el formulario de nueva entrada.
type PostInput struct {
Title string `json:"title" validate:"required,max=80"`
}
func (p *posts) store(c *tug.Ctx) error {
var in PostInput
if err := c.BindValid(&in); err != nil {
return err // de vuelta al formulario, con los errores
}
p.mu.Lock()
p.list = append(p.list, Post{ID: len(p.list) + 1, Title: in.Title})
p.mu.Unlock()
c.Flash("success", "Post created")
return c.RedirectRoute("posts.index")
}y su ruta, en newApp:
app.Post("/posts", p.store).Name("posts.store")c.BindValid llena in con la petición y lo comprueba con las etiquetas
validate, que son reglas de go-playground/validator. Si algo está mal,
devuelve los errores, y el handler los devuelve a su vez: el navegador
vuelve al formulario, y la página los recibe en su prop errors, como
"title is required" o "title must be at most 80 characters". c.Flash deja
un mensaje para la próxima página que se muestre, y el Layout del kit de
inicio se encarga de mostrarlo. c.RedirectRoute envía al navegador a una
ruta con nombre, con un 303 después de un POST, para que la siga con un
GET.
En Index.tsx, importa Form junto a Head, y route:
import { Form, Head } from '@inertiajs/react'
import { route } from '../../tug/routes'y añade el formulario debajo de la lista:
<Form action={route('posts.store')} method="post" resetOnSuccess className="form">
{({ errors, processing }) => (
<>
<label>
Title
<input name="title" />
</label>
{errors.title && <p className="error">{errors.title}</p>}
<button type="submit" disabled={processing}>
Add post
</button>
</>
)}
</Form>El <Form> de Inertia envía cada campo por su name, que es el nombre json
de un campo del struct de Go, y pasa a sus hijos los errors con esos
mismos nombres. resetOnSuccess vacía el campo en cuanto se añade la
entrada. Las clases form y error son del CSS del kit de inicio.
La página principal del kit de inicio también comprueba su campo al salir
de él, antes de enviar el formulario: el campo llama a validate('name')
cuando pierde el foco, y BindValid responde a esa petición, una petición
de Precognition, con los errores del campo, y el handler se detiene ahí.
Aquí funciona igual con el validate que el formulario pasa a sus hijos y
onBlur={() => validate('title')} en el campo.
Formularios y sesiones explica el resto: las comprobaciones
propias del handler, las bolsas de errores, los datos flash y la sesión.
go test ./...El main_test.go del kit de inicio prueba la aplicación como la usa el
cliente de Inertia, sin navegador ni compilación del frontend, con el
paquete tugtest. newClient crea la aplicación con newApp, una
compilación vacía y una clave propia, y devuelve un tugtest.Client, un
navegador con la aplicación abierta. Sus visitas reciben cada página como
JSON, su componente y sus props, y conserva la cookie de sesión de una
visita a la siguiente, que es lo que lleva los errores y el mensaje flash
de un formulario a la página que viene después. Dos pruebas para las
entradas, en main_test.go:
func TestANewPostIsInTheList(t *testing.T) {
c := newClient(t)
c.Get("/posts")
r := c.Post("/posts", map[string]any{"title": "Hello, tug"})
if r.Location() != "/posts" {
t.Fatalf("got %v, want back to the list", r)
}
r = r.Follow()
if posts := tugtest.Props(r, PostsIndex).Posts; len(posts) != 1 || r.Page.Flash["success"] != "Post created" {
t.Fatalf("posts %v, flash %v", posts, r.Page.Flash)
}
}
func TestAPostWithoutATitleComesBackWithWhatToFix(t *testing.T) {
c := newClient(t)
c.Get("/posts")
if errs := c.Post("/posts", map[string]any{"title": ""}).Follow().Errors(); errs["title"] != "title is required" {
t.Fatalf("errors %v", errs)
}
}c.Post devuelve la respuesta de la aplicación, aquí una redirección, y
Follow la sigue hasta la página siguiente, como hace el navegador: la
lista o, para una entrada sin título, la página desde la que se envió el
formulario, con los errores. tugtest.Props lee las props de la página en
el PostsIndexProps que declara su tug.Page, y hace fallar la prueba en
cualquier otra página. Pruebas explica el resto.
npm run typecheck comprueba el frontend con los tipos que escribió
tug gen.
tug buildtug build escribe los tipos, comprueba los tipos del frontend, lo compila
con Vite en public/build y compila la aplicación en un único binario,
./blog, con el frontend dentro: main.go incrusta public/. El binario
del kit de inicio ocupa unos 10 MB y es estático, así que funciona sin nada
más instalado.
El binario toma la configuración de su entorno, no de .env:
APP_KEY=base64:... ADDR=127.0.0.1:8080 ./blogSin una APP_KEY, se detiene y lo dice. Una aplicación desplegada necesita
su propia clave: tug key muestra una, que APP_KEY acepta tal cual. La
aplicación escucha en ADDR, o en el PORT que definen plataformas como
Cloud Run y Fly.io, o si no, en :8080. Deja APP_DEBUG sin definir, para
que los detalles de un 500 se queden en el log.
El Dockerfile de la aplicación compila el frontend con Node y el binario
con Go, y pone solo el binario en una imagen distroless:
docker build -t blog .
docker run -p 8080:8080 -e APP_KEY=base64:... blogLa compilación de la imagen no ejecuta tug: su compilación con Vite usa el
resources/js/tug del directorio, que el .gitignore del kit de inicio no
excluye, para que vaya al repositorio junto con el código Go a partir del
cual se escribe. Despliegue explica el resto: la
imagen, el entorno y los proxies.
- Rutas y handlers: rutas, grupos, middleware,
Ctx, binding y errores. - Páginas: props y tipos de props, props compartidas, redirecciones, páginas de error y Vite.
- Formularios y sesiones: validación, Precognition, mensajes flash, sesiones y CSRF.
- Cuentas: el kit de inicio de autenticación y los paquetes
authymail. - TypeScript: lo que
tug genescribe a partir de los tipos de Go. - Despliegue:
tug build, el Dockerfile y el entorno. - La CLI: cada comando en detalle.
examples/inertia es una aplicación más
grande: entradas que se crean, editan y eliminan, una prop diferida y una
lista que carga más a medida que se desplaza.