| name | boilerplate-go |
| description | Scaffolds Go web applications with Datastar, Templ, DaisyUI, TailwindCSS, NATS, and optional features (Fabric.js whiteboard, LiveKit+Gemini voice AI, PocketBase/SQLite database). Use when starting a new Go web project, creating real-time features, building hypermedia-driven apps, or scaffolding Go backend with SSE support. Triggers for "new go project", "scaffold go app", "go web boilerplate", "datastar go", "templ project", "go realtime app", "hypermedia go", "go sse server", "tailwind daisyui", "go ui components", or when user wants Go backend with reactive frontend. Also triggers for AI/LLM integration needs when involving voice: "voice AI", "voice bot", "livekit gemini", "real-time voice", "audio AI". For text-only AI use the goai skill instead.
|
Go Web Application Boilerplate (v1.0.0 Ready)
Go boilerplate inspired by Northstar, featuring:
⚠️ Datastar v1.0.0 Required - This boilerplate uses Datastar v1.0.0 (not RC.8). See Installation below.
When to Activate This Skill
Activate this skill when the user wants:
| Intent | Example Prompt |
|---|
| Create Go web project from scratch | "create a new go web app" |
| Add real-time/SSE | "add real-time updates to my Go app" |
| UI with ready-made components | "add a dashboard with Tailwind components" |
| Hypermedia-driven app | "build like Datastar/HTMX style app in Go" |
| Voice AI with LiveKit | "add a voice assistant to my app" |
| Collaborative whiteboard | "add a collaborative whiteboard" |
| Database | "add persistence with SQLite/PocketBase" |
🚨 MANDATORY: templ for ALL HTML
Read this first: MANDATORY_TEMPL_USAGE.md
This project has a ZERO-TOLERANCE policy for HTML in Go source files.
- ALWAYS create
.templ files for HTML
- NEVER use
fmt.Sprintf with HTML tags
- NEVER use indexed format specifiers (
%[N]s)
- Blocked by CI:
grep -r 'fmt\.Sprintf.*<' must return empty
Quick Start
1. Ask the User: Which Features Are Needed?
Not every project needs everything. Use this decision tree:
Go Web Project
├── Need UI?
│ ├── YES → DaisyUI (always included by default)
│ └── NO → Skip to "Need data?"
│
├── Need real-time?
│ ├── Simple Pub/Sub (NATS Core) → fire-and-forget messaging
│ ├── With persistence/history (JetStream) → streams + replay
│ └── Reactive frontend only (Datastar SSE) → no NATS needed
│
├── Need database?
│ ├── Simple (1 instance, local) → SQLite
│ ├── Advanced (multi-instance, auth, REST, realtime) → PocketBase
│ └── None → in-memory data or NATS KV
│
├── Need voice AI?
│ ├── YES → LiveKit + Gemini Live API
│ └── NO → Skip
│
├── Need whiteboard?
│ ├── YES → Fabric.js
│ └── NO → Skip
2. Decision Checklist
Before starting, confirm with the user:
## Project Configuration
- [ ] **UI**: DaisyUI + TailwindCSS (default, always recommended)
- [ ] **Real-time messaging**: NATS Core / JetStream / None
- [ ] **Database**: SQLite / PocketBase / None
- [ ] **Voice AI**: LiveKit + Gemini / None
- [ ] **Whiteboard**: Fabric.js / None
- [ ] **Module name**: `github.com/user/projectname`
- [ ] **Deploy target**: server.renatocaliari.com / other / none
3. Deploy & Versioning (OPTIONAL)
⚠️ Only generate this section if the user confirms a deploy target.
Ask the user: "Este projeto será deployado em produção? Onde?"
Target Options
| Target | Action |
|---|
server.renatocaliari.com | Generate full CI/CD pipeline with ghcr.io + cron |
other | Generate pipeline with placeholders ({{SERVER_HOST}}, {{IMAGE_NAME}}) |
none | Skip this section entirely |
If Deploy Target = server.renatocaliari.com
Generate these files with concrete values (no placeholders):
Branch note: The examples use main (GitHub default). Use master if the project's default branch is master.
.github/workflows/deploy.yml:
name: Build and Publish Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
packages: write
contents: read
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: docker/setup-buildx-action@v3
- run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- id: version
run: |
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- uses: docker/metadata-action@v5
with:
images: ghcr.io/{{GITHUB_REPO}}
tags: |
type=ref,event=branch
type=sha
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
build-args: |
CGO_ENABLED=0
VERSION=${{ steps.version.outputs.version }}
.github/workflows/release.yml:
name: Release
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: go
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
target-branch: ${{ github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Auto-merge Release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR="${{ steps.release.outputs.pr }}"
if [ -n "$PR" ] && [ "$PR" != "true" ]; then
gh pr merge --auto --merge "$PR" \
--subject "chore: auto-merge release PR"
fi
Also create release-please-config.json:
{
"packages": {
".": {
"release-type": "go"
}
}
}
And .release-please-manifest.json:
{
".": "0.0.0"
}
Important: release-please requires conventional commits format. Agents must use:
fix: descricao for patch bumps
feat: descricao for minor bumps
chore:, docs:, etc. for no bump
- Avoid emoji prefixes like
:bug: fix: — release-please ignores these
⚠️ Critical prs_created trap: The release-please-action outputs prs_created as a boolean (true/false), not a PR number. Using ${{ steps.release.outputs.prs_created }} in shell will expand to <<< "true" and break. Always use steps.release.outputs.pr (actual PR number) instead.
update.sh (on server at /opt/{{APP_NAME}}/update.sh):
#!/bin/bash
set -e
IMAGE="ghcr.io/{{GITHUB_REPO}}"
CONTAINER_NAME="{{APP_NAME}}"
TOKEN_FILE="/opt/{{APP_NAME}}/.gh_token"
log() { echo "$(date): $1" | tee -a /opt/{{APP_NAME}}/update.log; }
log "Checking for updates..."
echo "$(cat $TOKEN_FILE)" | docker login ghcr.io -u renatocaliari --password-stdin > /dev/null 2>&1
docker pull "$IMAGE:latest" > /dev/null 2>&1
REMOTE_ID=$(docker inspect "$IMAGE:latest" --format="{{.Id}}" 2>/dev/null || echo "")
LOCAL_ID=$(docker inspect "$CONTAINER_NAME" --format="{{.Image}}" 2>/dev/null || echo "")
if [ -z "$LOCAL_ID" ]; then
log "Container not running, will start..."
fi
if [ "$REMOTE_ID" != "$LOCAL_ID" ]; then
log "New version detected ($REMOTE_ID), deploying..."
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
SESSION_SECRET=$(openssl rand -base64 32 2>/dev/null || head -c32 /dev/urandom | base64)
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 127.0.0.1:8080:8080 \
-e SESSION_SECRET="$SESSION_SECRET" \
-v /opt/{{APP_NAME}}/data:/app/data \
"$IMAGE:latest"
log "SUCCESS: Deployed new version (image: $REMOTE_ID)"
else
log "No changes detected, skipping restart"
fi
Dockerfile (multi-stage):
- Builder:
golang:{{GOVSN}}-alpine + templ generate + go build -ldflags="-X main.Version=${VERSION}"
- Runtime:
alpine:3.21 (no root, healthcheck via TCP port check)
- Expose:
8080
If Deploy Target = other
Same structure but use placeholders that the agent/user must fill:
{{IMAGE_NAME}} = full image path
{{SERVER_HOST}} = deploy server SSH host
{{APP_DIR}} = path on server
{{DEPLOY_TOKEN_PATH}} = path to ghcr.io token
Go Configuration Variables
Go projects use environment variables directly (no .env by default). For production, pass via docker run -e VAR=value. For local dev, optionally add github.com/joho/godotenv if user requests .env support.
Version Display in UI
To show version in the app UI, inject via ldflags at build time:
go build -ldflags="-X main.Version=${VERSION}" -o app ./cmd/web/
In Go code:
var Version = "dev"
func main() {
cfg := config.Load()
cfg.Version = Version
}
In config.go:
type Config struct {
Version string
}
Pass version to templates via page data structs:
type HomePageData struct {
Sessions []SessionCardData
Version string
}
4. Generated Structure
project/
├── cmd/web/main.go # Entry point
├── config/
│ ├── config.go # Configuration
│ ├── config_dev.go # Dev config
│ └── config_prod.go # Prod config
├── router/router.go # Main router
├── nats/nats.go # NATS setup
├── features/ # Self-contained features
│ ├── common/
│ │ ├── layouts/base.templ
│ │ └── components/
│ ├── index/ # Home page
│ ├── todos/ # CRUD example (NATS KV)
│ ├── counter/ # Global vs user state
│ ├── monitor/ # System info
│ ├── sortable/ # Lit + SortableJS
│ ├── reverse/ # Streaming demo
│ ├── whiteboard/ # [OPTIONAL] Fabric.js
│ └── voice-training/ # [OPTIONAL] LiveKit + Gemini
├── web/
│ └── resources/
│ └── styles/
│ └── styles.css # DaisyUI + Tailwind
├── go.mod
└── Taskfile.yml
Feature Modules (Self-Contained)
Each feature in features/<name>/ is 100% self-contained:
- Its own routes, handlers, services
- Its own static assets (via
go:embed)
- Its own templates and components
How to Add/Remove Features
cp -r boilerplate-go/assets/scaffold/features/whiteboard myproject/features/
rm -rf myproject/features/whiteboard
Pattern: Embedded Assets (go:embed)
package whiteboard
import (
"embed"
"io/fs"
"net/http"
)
var staticEmbed embed.FS
func StaticFS() http.FileSystem {
fsys, _ := fs.Sub(staticEmbed, "static")
return http.FS(fsys)
}
Detailed Architectural Decisions
UI: DaisyUI (Always Recommended)
DaisyUI is ready-made TailwindCSS components. Always recommended for Go web projects because:
- 0 custom JavaScript for basic UI
- Consistent themes (automatic light/dark)
- Accessible components by default
- Customizable via Tailwind config
When NOT to use DaisyUI:
- Project needs 100% custom design (UI as differentiator)
- Team already has their own design system
Real-time: NATS Core vs JetStream
| Need | Solution |
|---|
| Simple broadcast (1→N) | NATS Core |
| Messages with history | JetStream |
| Work queues | JetStream Consumer |
| Simple Key-Value | JetStream KV |
| High performance, low latency | NATS Core |
Database: SQLite vs PocketBase
| Criteria | SQLite | PocketBase |
|---|
| Multiple instances | ❌ | ✅ |
| Built-in auth | ❌ | ✅ |
| Automatic REST API | ❌ | ✅ |
| Realtime subscriptions | ❌ | ✅ |
| Migrations | Manual | Automatic |
| Simplicity | ✅ | ✅ |
| Local data only | ✅ | ❌ |
Voice AI: When to Activate
Activate LiveKit + Gemini when:
- Voice assistant/chatbot that speaks
- Real-time transcription - meetings, calls
- Visual assistants - screen sharing + voice
- Automated IVR/NPS - phone support
- Remote education - tutoring with voice
Do NOT activate (use goai SDK instead):
- Text/chat generation
- Embeddings
- Image analysis
- Function calling without voice
Datastar v1.0.0 Patterns
⚠️ CRITICAL: This boilerplate uses Datastar v1.0.0. Read this section carefully!
Datastar v1.0.0 Installation
Option 1: CDN (Recommended for quick setup)
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"></script>
Option 2: Self-hosted (Included in boilerplate)
The boilerplate includes datastar.js at web/resources/static/datastar/datastar.js.
<script defer type="module" src="/static/datastar/datastar.js"></script>
Option 3: npm/deno/bun
import 'https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js'
HTML Template Setup
Your HTML template must include the Datastar script:
<!DOCTYPE html>
<html lang="en">
<head>
<script defer type="module" src="/static/datastar/datastar.js"></script>
</head>
<body>
{ children... }
</body>
</html>
Core Attributes
| Attribute | Example | Purpose |
|---|
data-on:click | data-on:click="@post('/api/action')" | Click handler |
data-init | data-init="$count = 1" | Optional: executes expression on element init |
data-signals | data-signals="{count: 0}" | Reactive state |
data-bind | data-bind="model" | Two-way binding |
data-text | data-text="$count" | Text content |
data-class | data-class="{'text-primary': $active}" | Conditional classes |
data-show | data-show="$visible" | Conditional visibility |
Signal Philosophy (IMPORTANT)
⚠️ Overusing signals typically indicates trying to manage state on the frontend.
Favor fetching current state from the backend rather than pre-loading and assuming frontend state is current.
Good rule of thumb:
- ✅ Use signals for user interactions (e.g., toggling element visibility, accordion state)
- ✅ Use signals for sending new state to backend via form bindings
- ❌ Don't use signals to store fetched data (fetch it when needed instead)
- ❌ Don't pre-load entire objects into signals and manage frontend state
Why this matters:
- Backend is the source of truth, not the frontend
- Signals are ephemeral - they don't persist across page loads
- Pre-loaded state can become stale when data changes elsewhere
Form Submissions
Standard forms (small data):
<form data-on:submit={`@post('/api/action', {contentType: 'form'})`}>
<input name="name" data-bind="name">
<button type="submit">Submit</button>
</form>
Backend handler:
func handler(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r)
r.ParseForm()
name := r.FormValue("name")
}
⚠️ Important: Always use {contentType: 'form'} for form submissions to send form-encoded data, not JSON signals.
What NOT to Use
- ❌ HTMX
- ❌ Alpine.js
- ❌ Vanilla JavaScript to replace Datastar functionality
- ❌ Other reactive frameworks
⚠️ Read references/datastar/patterns.md for complete patterns.
Key Concepts
- Signals - Reactive state in the frontend (JavaScript)
- SSE - Server-Sent Events for push updates
- Patches - HTML fragments updated by the server
- Indicators - Visual loading states
Example: Counter
type CounterSignals struct {
Global uint32 `json:"global"`
User uint32 `json:"user"`
}
templ Counter(signals CounterSignals) {
<div data-signals={ templ.JSONString(signals) }}>
<button data-on:click={ datastar.PostSSE("/counter/increment/global") }>
+ Global
</button>
<span data-text="$global"></span>
</div>
}
Example: TodoMVC (NATS KV)
js.Set("todos", "key", []byte(jsonData))
<div data-signals={ templ.JSONString(TodoSignals{
Todos: []*Todo{},
Mode: "all",
}) }>
<ul>
for _, todo := range mvc.Todos {
@TodoRow(todo)
}
</ul>
</div>
References (Progressive Disclosure)
Working with Existing Projects
When updating an existing Go project to use Datastar v1, follow this checklist:
Migration Checklist
-
Datastar Script Setup
- Ensure datastar.js v1.0.0 is in
web/resources/static/datastar/
- Add to HTML:
<script defer type="module" src="/static/datastar/datastar.js"></script>
data-init is optional (not required) - only use when you need to execute an action on page/component load
-
Static Files Configuration
- Use build tags:
//go:build dev for dev, //go:build !dev for prod
- In dev:
http.FileServerFS(os.DirFS(StaticDirectoryPath))
- In prod:
embed.FS + hashfs.FileServer
- Test:
curl http://localhost:8080/static/datastar/datastar.js should return 200
-
Form Migration
- Add
name attribute to all inputs/textareas
- Add
data-bind for two-way sync
- Replace
data-on:input="$var = el.value" with data-bind="varName"
-
JSON Escaping
- Replace manual escape with
json.Marshal approach
- Test with special characters: newlines, quotes, unicode
-
Visibility Patterns
- Replace
class="hidden" + data-show with data-show only
- Test tab switching
Common Migration Issues
See Common Pitfalls section below.
Common Pitfalls
1. data-signals JSON Escaping
❌ Don't: Manual escaping with strings.ReplaceAll
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "'", "\\'")
✅ Do: Use json.Marshal
func escapeForJS(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
Note: json.Marshal returns the string with quotes included, so use %s directly in templates, not '%s'.
2. Form Data Not Sending
❌ Don't: Inputs without name attribute
<input data-bind="model">
✅ Do: Always include name
<input name="model" data-bind="model">
3. Textareas Not Syncing
❌ Don't: Only data-on:input
<textarea data-on:input="$prompt = el.value">
✅ Do: Use data-bind
<textarea name="prompt" data-bind="prompt">
4. Tabs Not Working
❌ Don't: Mix class="hidden" with data-show
<div data-show="$tab === 'a'" class="hidden">
✅ Do: Use only data-show
<div data-show="$tab === 'a'">
5. Loading State Stuck
❌ Don't: Forget to reset signal
repo.Save(data)
✅ Do: Reset signal after operation
repo.Save(data)
sse.MarshalAndPatchSignals(map[string]bool{"isLoading": false})
Troubleshooting
"Invalid or unexpected token" in console
Cause: Malformed data-signals JSON
Fix:
- Check json.Marshal escaping
- View page source and validate JSON
- Check for unescaped newlines/quotes
"POST body empty"
Cause: Inputs missing name attribute
Fix: Add name="fieldName" to all form inputs
"datastar not defined"
Cause: Script not loaded
Fix:
- Check 404 for datastar.js
- Verify static file serving
- Check script path
"Signals not updating"
Cause: Typically a data-bind or signal definition issue
Fix:
- Ensure
data-bind attribute is present on form elements
- Check signal name matches between definition and usage
- Verify server returns proper SSE response
Handler never called
Cause: Wrong data-on:click syntax
Fix: Use @post('/api/action') format with quotes and leading slash
Form data not received at backend
Cause: Missing {contentType: 'form'} in action
Fix: Use @post('/api/action', {contentType: 'form'}) and call r.ParseForm() in handler
Best Practices
- Self-contained features - Each feature in its own directory
- Delta sync - Send only what changed (not entire objects)
- KISS (SSE) - Prefer SSE over WebSockets for one-way updates
- Indicators - Always show loading states with
data-indicator
- Error boundaries - try/catch on media and storage operations
- View transitions - Use
@view-transition { navigation: auto; } for smooth navigation
Engineering Standards (CRITICAL — Always Enforce)
This project follows strict engineering principles. Violations block merge.
1. Maximum 500 lines per file
No .go, .templ, or .js file shall exceed 500 lines.
- Files approaching 400 lines MUST be split before adding new code.
- Use single-responsibility: one handler group per file, one repository domain per file.
- Split patterns:
handlers/chat.go (god function) → chat_send.go + chat_load.go + chat_types.go
db/repository.go (all repos) → session_repo.go + message_repo.go + settings_repo.go + feedback_repo.go
2. DRY: Zero-tolerance for SSE boilerplate
Never write this pattern manually:
var buf strings.Builder
component.Render(context.Background(), &buf)
sse.PatchElements(buf.String(), datastar.WithSelectorID("target"), datastar.WithModeInner())
Instead, use the shared helper on NarrativeService:
ns.renderAndPatch(sse, component, datastar.WithSelectorID("target"), datastar.WithModeInner())
Extract shared helpers BEFORE adding new code — never repeat patterns.
3. KISS: God functions are forbidden
No function shall exceed 100 lines.
Functions >100 lines MUST be refactored into smaller focused functions before adding new logic.
Common god functions: sendChatMessage, HandleChat, large handler switch statements.
4. LoC (Locality of Behavior) for Datastar
Prefer data-show, data-class, data-on:* over JavaScript DOM manipulation.
Only write JavaScript for things ONLY JS can do:
- Web Speech API (microphone recording)
- Drag-to-resize (60fps pointer tracking)
- Scroll position tracking (no
data-on:scroll in Datastar Free)
- Clipboard (Pro:
@clipboard(), Free: JS)
For visual feedback (class toggling, show/hide): ALWAYS use Datastar signals. Never classList.add/remove.
Small JS helpers should be colocated in Templ files via <script> blocks, not separate .js files.
5. fmt.Sprintf for HTML is forbidden
All HTML rendering MUST use Templ components. If existing fmt.Sprintf HTML is found, convert to Templ before adding new code.
6. Before adding any code
- Check if the target file is close to 500 lines (>400)
- Check if the same pattern already exists as a helper
- Check if the function you're editing exceeds 100 lines
- Check if
fmt.Sprintf with HTML tags can be replaced with a Templ component
Testing Protocol (MANDATORY — Frontend Changes)
After any browser-facing change:
- Load the
agent-browser skill — navigate pages, click buttons, verify no JS errors in console.
- Load the
dogfood skill — systematically explore the feature, test edge cases, find bugs.
- Only then consider the feature complete. Do NOT skip browser testing.
Use skill("agent-browser") and skill("dogfood") tools to load them.
Inspiration
This boilerplate is inspired by Northstar by Delaney Johnson.