A small starter template for full stack web apps using Go, Templ, Datastar and sqlc.
It is meant to be copied from. It shows the basic patterns (rendering pages, reading signals, patching the DOM over SSE, streaming live data) without adding a framework on top. Datastar can do a lot more than what is shown here, so treat this as a starting point.
- Home page with links to the two examples.
- Todo list: a regular CRUD table with live search. You can create, edit, toggle and delete todos, and each action updates the page over SSE without a reload.
- Realtime crypto prices: BTC, ETH and XRP prices streamed from the public Coinbase websocket feed (no API key needed) and pushed to the browser over SSE.
| Part | Tool |
|---|---|
| Server | Go standard library net/http |
| Templates | Templ |
| Frontend | Datastar + Tailwind (both via CDN) |
| Database | SQLite (modernc.org/sqlite, pure Go, no CGO) |
| Queries | sqlc |
- Go 1.25 or newer
- templ:
go install github.com/a-h/templ/cmd/templ@latest - sqlc:
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
makeWhen the terminal prints server ready on http://localhost:8080, open that address in your browser.
make (the same as make dev) generates the code, tidies the modules and starts the server, in that order. You can also run each step on its own:
| Command | Same as | What it does |
|---|---|---|
make dev |
Runs sqlc, templ, tidy and run in order |
|
make sqlc |
sqlc generate |
Generates Go code from sql/ into internal/db |
make templ |
templ generate |
Generates Go code from the .templ files |
make tidy |
go mod tidy |
Adds missing modules and removes unused ones |
make run |
go run . |
Starts the server |
make build |
go build . |
Builds a datastar-template binary |
You only need to run make sqlc again after you change something in sql/, make templ after you change a .templ file, and make tidy after you add or remove an import.
Note: the database is SQLite in memory, to keep things simple. It is created fresh on every start and filled with 5 sample todos, and everything is lost when the server stops. To keep your data, change ":memory:" in main.go to a file path such as "app.db".
.
├── main.go # opens the database, seeds data, starts the crypto price goroutine, starts the server
├── Makefile # dev (default), sqlc, templ, tidy, run and build
├── sqlc.yaml # sqlc config
├── static/ # static files served at /static/ (favicon, images, ...)
├── sql/
│ ├── schema.sql # table definitions (also embedded and run on startup)
│ └── query.sql # named queries that sqlc turns into Go functions
└── internal/
├── db/ # generated by sqlc, do not edit
├── server/
│ ├── server.go # routes
│ ├── handlers.go # HTTP handlers
│ ├── middleware.go # logging and trailing slash handling
│ └── utils.go # helpers for rendering templ components
├── views/ # templ components (*_templ.go files are generated)
└── crypto/
└── live_prices.go # websocket client for the Coinbase price feed
| Method | Path | What it does |
|---|---|---|
| GET | / |
Home page |
| GET | /todos |
Todo page |
| GET | /todos-search |
Filters the list (SSE) |
| POST | /todos |
Creates a todo (SSE) |
| PUT | /todos/{id}/toggle |
Toggles completed (SSE) |
| PUT | /todos/{id}/update |
Updates the description (SSE) |
| DELETE | /todos/{id} |
Deletes a todo (SSE) |
| GET | /crypto |
Crypto prices page |
| GET | /crypto-prices-stream |
Long-lived SSE stream of price updates |
| GET | /static/... |
Files from the static/ folder |
| GET | /health |
Health check, returns OK |
Datastar keeps client-side state in signals. In this template, each page's signals are defined once as a Go struct and used on both sides.
The struct lives next to the page in internal/views/todo.templ:
type TodoPageSignals struct {
SearchParam string `json:"searchParam"`
NewDescription string `json:"description"`
}Frontend. The page renders the struct as JSON into data-signals, which creates the signals in the browser. The JSON tags become the signal names, and inputs bind to them with data-bind:
<div data-signals={ templ.JSONString(TodoPageSignals{}) }>
<input data-bind="searchParam" data-on:input__debounce.200ms="@get('/todos-search')" />
</div>Backend. When Datastar makes a request (@get, @post, ...) it sends the current signals with it. The handler reads them back into the same struct:
signals := &views.TodoPageSignals{}
if err := datastar.ReadSignals(r, signals); err != nil { ... }
// signals.SearchParam now holds what the user typedBecause both sides use the same struct, the JSON tag is the only name to keep in sync. If you rename a tag, update the matching data-bind too.
sqlc.yaml tells sqlc to read sql/schema.sql and sql/query.sql and write a Go package called db into internal/db:
version: "2"
sql:
- engine: "sqlite"
schema: "sql/schema.sql"
queries: "sql/query.sql"
gen:
go:
package: "db"
out: "internal/db"For each table you get a struct (for example db.Todo), and for each named query you get a method (for example -- name: ListTodos :many becomes queries.ListTodos(ctx)).
Templates take these generated structs directly, so there is no separate view model to maintain:
templ TodoItem(todo db.Todo) { ... }To add a feature, write the SQL, run sqlc generate, and use the new struct and method in your handlers and templates.
Handlers that respond to Datastar requests do not return a full page. They render a templ component to a string and send it as a Server-Sent Event:
html, _ := compToString(views.TodoItem(todo))
sse := datastar.NewSSE(w, r)
sse.PatchElements(html)Datastar finds the element in the page with the same id and morphs it in place. That is why components such as TodoItem and PriceCard always render a stable id. You can also target a different element or change the patch mode, for example adding a new todo to the top of the list:
sse.PatchElements(html, datastar.WithSelectorID("todo-list"), datastar.WithModePrepend())The crypto page shows how to push data that the server receives from somewhere else.
crypto.Enginekeeps a single websocket connection to Coinbase and reconnects if it drops.- Each browser tab that opens
/cryptostarts a long-lived SSE request withdata-init="@get('/crypto-prices-stream')". - That handler subscribes to the engine and sends updated price cards every 250 ms. It batches updates because the feed can send many per second.
You can reuse the same pattern for notifications, dashboards, chat, or anything else where the server needs to push updates.
A few things to try once the app is running, to see how the pieces connect.
- Messages from the server in your browser console. Open the browser devtools console, then go to the todo page, edit a todo and press Enter (or click outside the input). You will see
Successfully updated task N. Nothing in the frontend code prints that. ThetodosUpdatehandler ininternal/server/handlers.gosends it withsse.ConsoleLog(...)in the same SSE response as the updated todo. - Watch the SSE responses. In the devtools Network tab, click on a request such as
todos-searchwhile you type in the search box. The response is plain text events containing the HTML that Datastar patches into the page. - Check the signals. Type in the search box, then look at the
datastarquery parameter on thetodos-searchrequest. That is theTodoPageSignalsstruct as JSON, which the handler reads back withdatastar.ReadSignals. - Open two tabs on the crypto page. Both get live prices from the same single websocket connection to Coinbase.
- Title: change the
<title>tag ininternal/views/shell.templ. - Favicon: put your icon at
static/. It is already linked ininternal/views/shell.templ, so change the<link rel="icon">tag there if you use a different file name or format. - Navigation: edit the
navcomponent ininternal/views/shell.templ. - Port: set in
main.go(default:8080), or create an ENV config and loader.
- Tailwind: the template loads the Tailwind browser build (
@tailwindcss/browser) from a CDN, which compiles styles in the browser on every page load. It is meant for development only. For production, use the Tailwind CLI to build a CSS file, put it instatic/, and replace the script tag ininternal/views/shell.templwith<link rel="stylesheet" href="/static/styles.css">. - Switch SQLite from in-memory to a file (see above), or change the sqlc engine to Postgres or MySQL.
This template does not include any tests. That is on purpose: it is meant to be copied and changed, and tests for the example todo and crypto pages would mostly be deleted along with them. Add tests that fit your own app as you build it. Go's standard testing package and net/http/httptest work well for testing handlers, and you can run them with go test ./....
- Datastar docs: https://data-star.dev
- Datastar Go SDK: https://github.com/starfederation/datastar-go
- Templ docs: https://templ.guide
- sqlc docs: https://docs.sqlc.dev
This is my personal boilerplate, shaped around how I like to build things. If it fits the way you work, go ahead and use it. If it doesn't, no worries, and good luck with whatever you build.