A profile form with one extra field: an avatar. The file rides the ordinary
form submit. LiveTemplate checks it against a type and size whitelist, and once
the form saves, moves it somewhere permanent and shows it back straight away.
No page reload, no custom JavaScript, and no upload attribute at all.
The full source is
examples/avatar-upload/.
This recipe uses the Volume mode — LiveTemplate's default. The browser
sends the bytes to the server, which stages them on local disk; the app then
owns the file's lifecycle (here: move it into uploads/). It is the right
default when you want the server to see and keep the bytes.
Volume is one of four upload modes; the mode comes purely from server config, and the markup never changes. To stream bytes straight to remote storage with zero local disk, or to let the browser upload directly to S3/GCS, see the Upload Modes recipe and the Upload reference.
WithUpload declares the named upload and its validation. With no Mode
set, the field is Volume (the default); with no Dir, bytes stage to a temp
directory and the app moves them where it wants on completion:
lt := livetemplate.Must(livetemplate.New("avatar-upload",
livetemplate.WithParseFiles("avatar-upload.tmpl"),
livetemplate.WithUpload("avatar", livetemplate.UploadConfig{
Accept: []string{"image/jpeg", "image/png", "image/gif"},
MaxFileSize: 5 * 1024 * 1024, // 5MB
MaxEntries: 1, // single file
}),
))
handler := lt.Handle(&ProfileController{}, livetemplate.AsState(&ProfileState{
Name: "John Doe",
Email: "john@example.com",
}))
Set
Dir: "uploads"(withMode: livetemplate.UploadModeVolume) to have LiveTemplate retain the staged file in a directory you own, instead of the stage-then-move pattern below. See the Volume mode reference.
The avatar rides along in the same multipart/form-data POST as the text
fields, so one action reads both. Text fields come from ctx.GetString;
completed files come from ctx.GetCompletedUploads. Each entry carries the
server-side staging path in entry.TempPath — move it to permanent storage
and record the URL in state:
func (c *ProfileController) UpdateProfile(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) {
state.Name = ctx.GetString("name")
state.Email = ctx.GetString("email")
for _, entry := range ctx.GetCompletedUploads("avatar") {
ext := filepath.Ext(entry.ClientName)
dst := filepath.Join("uploads", fmt.Sprintf("avatar-%s%s", entry.ID, ext))
if err := os.Rename(entry.TempPath, dst); err != nil {
return state, fmt.Errorf("failed to save avatar: %w", err)
}
state.AvatarURL = "/" + dst
}
ctx.SetFlash("success", "Profile updated")
return state, nil
}
The file input is a plain <input type="file"> with no upload attribute —
its name="avatar" is what pairs it with the WithUpload("avatar", …)
registration on the server. The file travels inside the form's ordinary
multipart/form-data submit, so this is Tier 1: standard HTML, no lvt-*
needed. The {{range .lvt.Uploads "avatar"}} block renders the resulting
upload entries, and .lvt.HasUploadError / .lvt.UploadError surface
validation failures:
<form method="POST" name="updateProfile" enctype="multipart/form-data" lvt-form:preserve>
<input type="text" name="name" value="{{.Name}}" required>
<input type="email" name="email" value="{{.Email}}" required>
<input type="file" name="avatar"
accept="image/jpeg,image/png,image/gif">
{{range .lvt.Uploads "avatar"}}
<small><strong>{{.ClientName}}</strong> — {{.Progress}}%</small>
<progress value="{{.Progress}}" max="100"></progress>
{{if .Error}}<del>{{.Error}}</del>{{else if .Done}}<ins>Upload complete!</ins>{{end}}
{{end}}
{{if .lvt.HasUploadError "avatar"}}<del>{{.lvt.UploadError "avatar"}}</del>{{end}}
<button type="submit">Save Profile</button>
</form>
{{if .AvatarURL}}<img src="{{.AvatarURL}}" alt="Avatar">{{end}}
lvt-form:preserve keeps the chosen file and typed text across the live
re-render so a validation error doesn't wipe the form.
Want a live progress bar? On this Tier 1 path the whole file arrives in one multipart POST, so the entry is already complete by the time it renders — the
<progress>element reports the finished upload rather than animating toward it. For byte-by-byte progress the input needslvt-upload="avatar", which switches it to the chunked WebSocket transport; seeupload-autouploadand the Upload Modes recipe.
UploadConfig enforces the whitelist before your handler runs — a file
that fails gets marked invalid, surfaces through .lvt.UploadError, and never
appears in GetCompletedUploads:
.txt or .pdf) — rejected by Accept.MaxFileSize.MaxEntries: 1).MIME types can be spoofed, so for security-critical uploads also validate the file's actual content in your handler — see Content validation.
cd examples/avatar-upload
GOWORK=off go run main.go
Open http://localhost:8080, choose an image, and click Save Profile to watch the progress bar fill and the avatar appear. The end-to-end test drives exactly that flow in a real browser.
lvt-upload.