tug v0.45.0

Guide 01

Getting started

On this page
  1. What you need
  2. Install tug
  3. Make an app
  4. Run it
  5. What's in the app
  6. Add a page
  7. Add a form
  8. Test it
  9. Build it
  10. Where next

This page makes a new app and runs it, adds a page and a form to it, tests them, and builds the app into one binary.

What you need

  • Go 1.26 or later.
  • Node.js 22.12 or later, or 20.19 or later in Node 20: Vite 8, which builds the frontend, needs one of them. The app's Dockerfile builds with Node 24.
  • npm, which comes with Node. tug new installs the app's packages with it.

Install tug

Shell
go install github.com/cuonggt/tug/cmd/tug@latest
tug version

go install puts tug in $(go env GOPATH)/bin, or in $GOBIN when that's set, and that directory needs to be on your PATH. tug version prints the version installed, such as tug v0.45.0. The apps tug new makes require that version.

Make an app

Shell
tug new blog
cd blog

tug new makes the directory blog, which mustn't exist yet or must be empty, and puts an app in it that's ready to run:

  • the starter's files, with a Go module named after the directory, which -module github.com/you/blog changes;
  • a .env with a fresh APP_KEY, the 32 random bytes that encrypt the session cookie, and APP_DEBUG=true, which shows a server error's details on a page: the error, where it came from, and the request;
  • the Go modules, with go mod tidy, and the frontend's packages, with npm install;
  • the TypeScript of the app's pages and routes, in resources/js/tug, which it builds the app once to write.

For an app with accounts, add -auth:

Shell
tug new -auth blog

That lays a second starter over the first. People register and verify their email, log in, with a code from an authenticator app too once they turn that on, reset a forgotten password with a link sent by email, and change their profile and photo, password and appearance in their settings. The users are in SQLite, in app.db, their photos in files/, and the frontend has Tailwind and shadcn/ui. With -postgres or -mysql, the users are in Postgres or MySQL instead, which the app's compose.yaml runs: docker compose up -d before tug dev. The code is the app's own, the handlers in auth.go and the files beside it, and the database's SQL in migrations/, which makes its tables, and the _db.go files, to change as the app needs (Migrations). Until MAIL_HOST is set, mail isn't sent: under tug dev it's kept in the app's mailbox, at /_tug/mail, which shows each mail as a mail program would, and the app's output has a line for each, with its link. Accounts has the rest.

For pages rendered on the server, for search engines and pages that show before their scripts run, add -ssr, to either: Node renders them, beside the app. Server-side rendering has the rest.

The frontend is React, or with -vue or -svelte, Vue or Svelte: the same app, with the same Go, and its pages as .vue or .svelte components where React's are .tsx, which -auth and -ssr go with as well. This page goes on with the plain starter, in React.

Run it

Shell
tug dev
tug  │ serving http://localhost:8080, built in 268ms

Open http://localhost:8080. tug dev runs two servers and shows their output with its own, each line labeled tug, app or vite:

  • Vite's dev server, npm run dev, on localhost:5173, which serves the frontend's modules to the browser.
  • The app, the Go server the browser visits, built into .tug/app. When port 8080 is taken, tug dev takes the next free one and says so. ADDR or PORT, in .env or the environment, picks one instead.

The page comes from the Go server, and its scripts from Vite: while Vite runs, the plugin in vite.config.ts writes its address to public/hot, which the Go server reads each time it renders a page.

As you work:

  • Go files, go.mod and go.sum, and templates (.html, .tmpl, .gohtml): tug builds the app again, writes the TypeScript types if they've changed, restarts the app, and reloads the browser.
  • A build that fails: its errors show under app, and the app from the last build that worked keeps running until a save fixes it.
  • The frontend, under resources/: Vite sends the change to the browser, and a React component updates in place, keeping its state.
  • .env: it's read when tug dev starts. Stop it with Ctrl-C and start it again.

The CLI has the details.

What's in the app

blog/
├── main.go              the server: its pages, routes and handlers
├── flash.go             the flash data's keys, which tug gen types
├── main_test.go         its tests
├── app.html             the HTML every page is rendered into
├── resources/
│   ├── css/app.css
│   └── js/
│       ├── app.tsx      the frontend's entry: finds each page's component
│       ├── Layout.tsx   what every page has around it, the flash message too
│       ├── pages/       a component for each page: Home.tsx and Error.tsx
│       └── tug/         written by tug gen, not by hand: pages.ts, routes.ts
├── public/              put into the binary; Vite builds into public/build
├── vite.config.ts       Vite's settings, and the plugin tug dev works with
├── go.mod, package.json, tsconfig.json
├── .env                 read by the tug commands, not the app; not committed
├── .env.example         the same without the key, to commit
├── Dockerfile           the app as one binary on a distroless image
├── AGENTS.md            how to change the app, for a coding agent
├── CLAUDE.md            AGENTS.md, for Claude Code
└── README.md

main.go is in two parts. main reads the app's settings from the environment, APP_KEY with session.KeysFromEnv, and ADDR, PORT and APP_DEBUG with tug.ConfigFromEnv, and runs the app. newApp puts the app together from them: Vite's tags, the Inertia pages with app.html around them, the sessions, the error page, the middleware, the route that serves the frontend's build under /build/, and the app's own routes. The tests call newApp with settings of their own.

AGENTS.md is for a coding agent, which most read as they start: how to add a page, a form and a test, as this guide goes on to, the rules the code doesn't show, as never editing resources/js/tug, and the commands that check a change. CLAUDE.md imports it, as Claude Code reads AGENTS.md itself only while there's no CLAUDE.md. They're the app's, to change as it grows: tug doesn't write them again.

Add a page

A page is a React component in resources/js/pages, and the Go that renders it: a struct of its props, a tug.Page that ties them to the component, a handler, and a route. This one lists the blog's posts. Add to main.go, with slices and sync in its imports:

Go
// Post is a post on the blog.
type Post struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
}

// posts keeps the posts in memory, so they're gone when the app stops.
// Requests are handled concurrently, so the list has a lock.
type posts struct {
	mu   sync.Mutex
	list []Post
}

// PostsIndexProps are the props of 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})
}

and in newApp, under the other routes:

Go
	p := &posts{}
	app.Get("/posts", p.index).Name("posts.index")

tug.Page[PostsIndexProps]("Posts/Index") declares the component resources/js/pages/Posts/Index.tsx and the props it takes: Render takes a PostsIndexProps and nothing else. The json tags name the props as the component gets them, and a list with nothing in it goes out as [], never null. The route's name, posts.index, is how the Go and the frontend both refer to it without writing out its path. The posts are made in newApp, so each test's app starts with none.

Save, and tug dev builds the app and writes its types again:

tug  │ main.go changed
tug  │ wrote resources/js/tug/pages.ts, resources/js/tug/routes.ts
tug  │ serving http://localhost:8080, built in 844ms

pages.ts now has the page's props, in TypeScript:

TypeScript
export interface Post {
  id: number
  title: string
}

export interface PostsIndexProps {
  posts: Post[]
}

The component goes in resources/js/pages/Posts/Index.tsx:

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'> is the page's own props and those every page shares, such as appName, which newApp shares with pages.Share. To link to the page from the home page, add Link to Home.tsx's import from @inertiajs/react, and this under the greeting:

TSX
<Link href={route('posts.index')}>Posts</Link>

route() knows the app's named routes. A name that isn't one, such as route('post.index'), doesn't type-check, and neither does a route with a wildcard called without its value. Values go by name: route('posts.show', { id: 42 }) is /posts/42 for a /posts/{id} route. Vite doesn't check types as it serves; your editor does, and so do npm run typecheck and tug build.

Add a form

The form adds a post. In Go, it's a struct of what the form sends, with the rules for each field in its validate tag, and a handler:

Go
// PostInput is what the new post form sends.
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 // back to the form, with the errors
	}
	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")
}

and its route, in newApp:

Go
	app.Post("/posts", p.store).Name("posts.store")

c.BindValid fills in from the request and checks it against the validate tags, which are go-playground/validator's rules. When something is wrong, it returns the errors, and the handler returns them: the browser goes back to the form, and the page gets them in its errors prop, such as "title is required" or "title must be at most 80 characters". c.Flash leaves a message for the next page shown, which the starter's Layout shows. c.RedirectRoute sends the browser to a named route, with a 303 after a POST, so that it follows with a GET.

In Index.tsx, import Form beside Head, and route:

TSX
import { Form, Head } from '@inertiajs/react'
import { route } from '../../tug/routes'

and add the form under the list:

TSX
<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>

Inertia's <Form> sends its inputs by their name, which are the json names of the Go struct's fields, and hands its children the errors under the same names. resetOnSuccess empties the input once the post is added. The form and error classes are the starter's CSS.

The starter's home page also checks its field as it's left, before the form is sent: its input calls validate('name') when it loses focus, and BindValid answers that request, a Precognition request, with the field's errors, and the handler stops there. The same works here with validate from the form's children, and onBlur={() => validate('title')} on the input. Forms and sessions has the rest: checks of the handler's own, error bags, flash data, and the session.

Test it

Shell
go test ./...

The starter's main_test.go tests the app the way Inertia's client uses it, without a browser or a frontend build, with package tugtest. newClient makes the app with newApp, an empty build and a key of its own, and returns a tugtest.Client, a browser with the app open. Its visits get each page as JSON, its component and props, and it keeps the session cookie from one visit to the next, which is what carries a form's errors and flash message to the page after it. Two tests for the posts, in main_test.go:

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 returns the app's answer, a redirect here, and Follow follows it to the page after, as the browser does: the list, or, for a post without a title, the page the form was sent from, with the errors. tugtest.Props reads the page's props into the PostsIndexProps its tug.Page declares, and fails the test on any other page. Testing has the rest.

npm run typecheck checks the frontend against the types tug gen wrote.

Build it

Shell
tug build

tug build writes the types, type-checks the frontend, builds it with Vite into public/build, and builds the app into one binary, ./blog, with the frontend inside: main.go embeds public/. The starter's binary is about 10 MB, and static, so it runs with nothing else installed.

The binary takes its settings from its environment, not from .env:

Shell
APP_KEY=base64:... ADDR=127.0.0.1:8080 ./blog

Without an APP_KEY, it stops and says so. A deployed app needs a key of its own: tug key prints one, which APP_KEY takes as it is. The app listens on ADDR, or on PORT as platforms such as Cloud Run and Fly.io set it, or else on :8080. Leave APP_DEBUG unset, so that a 500's details stay in the log.

The app's Dockerfile builds the frontend with Node and the binary with Go, and puts the binary alone on a distroless image:

Shell
docker build -t blog .
docker run -p 8080:8080 -e APP_KEY=base64:... blog

The image's build doesn't run tug: its Vite build uses the resources/js/tug in the directory, which the starter's .gitignore leaves in, to be committed with the Go it's written from. Deployment has the rest: the image, the environment, and proxies.

Where next

  • Routing and handlers: routes, groups, middleware, Ctx, binding, and errors.
  • Pages: props and prop types, shared props, redirects, error pages, and Vite.
  • Forms and sessions: validation, Precognition, flash, sessions and CSRF.
  • Accounts: the auth starter, and packages auth and mail.
  • TypeScript: what tug gen writes from the Go types.
  • Deployment: tug build, the Dockerfile, and the environment.
  • The CLI: each command in full.

examples/inertia is a bigger app: posts created, edited and deleted, a deferred prop, and a list that loads more as it scrolls.

tug