Forms#
Bialet handles forms with plain HTML and a few Wren methods. A <form>
sends data, your .wren file reads it, and you decide what happens next.
The Pattern#
Every form page follows the same three steps:
if (Request.isPost) {
// 1. Read the fields
var name = Request.post("name") || ""
// 2. Process (validate, save)
`users`.save({"name": name})
// 3. Redirect to avoid resubmission
return Response.redirect("/users")
}
// GET — show the form
return <form method="post">
<input name="name" />
<button>Save</button>
</form>
⚠️ Pitfall:
Request.post(name)returnsnullwhen the key is missing. Always guard with|| ""or a null check before string operations.
Subscribe Form (POST + Validation)#
A simple email subscription form with basic validation:
// subscribe.wren
var email = Request.post("email") || ""
var error = null
if (Request.isPost) {
if (!email.contains("@")) {
error = "Enter a valid email."
} else {
`INSERT INTO subscribers (email) VALUES (?)`.query(email)
return Response.redirect("/subscribe?ok=1")
}
}
return <main>
<h1>Subscribe</h1>
{{ Request.get("ok") && <p class="success">Subscribed!</p> }}
{{ error && <p class="error">{{ error }}</p> }}
<form method="post">
<label>Email: <input name="email" type="email" value="{{ email.safe }}" required /></label>
<button>Subscribe</button>
</form>
</main>
Key points:
Request.post("email") || ""reads the field safely!email.contains("@")is a quick sanity check — real email validation is complex; this catches most typos and empty submissions in one linevar error = nulland{{ error && ... }}renders the message inline instead of short-circuiting withreturnvalue="{{ email.safe }}"preserves the input on failed submissionreturn Response.redirect(...)prevents resubmission on refreshRequest.get("ok")shows a success message after redirecttype="email"andrequiredgive free browser-side validation
Never trust client-side validation alone. The
requiredattribute is a convenience for the user, not a security boundary.
Search Form (GET)#
For search or filtering, use GET and read query parameters with
Request.get():
// search.wren
var q = Request.get("q") || ""
var results = []
if (q != "") {
results = `SELECT * FROM posts WHERE title LIKE ?`.fetch("%" + q + "%")
}
return <main>
<h1>Search</h1>
<form method="get" action="/search">
<input name="q" value="{{ q.safe }}" placeholder="Search posts..." />
<button>Search</button>
</form>
{{ q != "" && <section>
<p>{{ results.count }} results for "{{ q.safe }}"</p>
<ul>
{{ results.map {|r| <li><a href="/post?id={{ r["id"] }}">{{ r["title"].safe }}</a></li> } }}
</ul>
</section> }}
</main>
Key points:
method="get"puts the query in the URL (/search?q=term)Request.get("q") || ""avoids null — use|| ""so.safedoesn’t crashif (q != "")checks if there’s a search term (empty string is truthy in Wren, soif (q)doesn’t work as a presence check)No redirect needed — GET forms are safe to refresh
value="{{ q.safe }}"preserves the search termEscape all user input with
.safewhen outputting to HTML
Login Form (POST + CSRF + Password)#
A login form with CSRF protection and password verification:
// login.wren
var session = Session.new()
var email = Request.post("email") || ""
var error = null
if (Request.isPost) {
if (!session.csrfOk) {
error = "Invalid form submission."
} else {
var password = Request.post("password") || ""
var user = `SELECT * FROM users WHERE email = ?`.first(email)
if (user && Util.verify(password, user["password"])) {
session.login(user["id"])
return Response.redirect("/dashboard")
}
error = "Invalid email or password."
}
}
return <main>
<h1>Login</h1>
{{ error && <p class="error">{{ error }}</p> }}
<form method="post">
{{ session.csrf }}
<label>Email: <input name="email" type="email" value="{{ email.safe }}" required /></label>
<label>Password: <input name="password" type="password" required /></label>
<button>Login</button>
</form>
</main>
Key points:
Session.csrfrenders a hidden token field — put it in every state-changing formSession.csrfOkverifies the token on submit — check it before processingUtil.verify(password, hash)checks the password against the stored hash (see Security for how to hash passwords withUtil.hash)var errorwith{{ error && <p class="error">{{ error }}</p> }}shows the message inline on a single form, no duplicationvalue="{{ email.safe }}"preserves the email on failed loginsession.login(id)persists the session on successreturn Response.redirect(...)on success, no redirect on failure
⚠️ Pitfall: never store plaintext passwords. Use
Util.hash(password)to create the hash, store that in the database, and verify withUtil.verify(password, storedHash). See the full Security guide for details on passwords, CSRF, and session defaults.
File Uploads#
Set enctype="multipart/form-data" on the form and read the file with
Request.file():
if (Request.isPost) {
var file = Request.file("attachment")
if (file) {
return <p>Uploaded: {{ file.name }} ({{ file.size }} bytes)</p>
}
}
return <form method="post" enctype="multipart/form-data">
<input type="file" name="attachment" />
<button>Upload</button>
</form>
Files are stored in the SQLite database. See the File Handling guide for the full API.
Pitfalls#
Request.post()returnsnull— always|| ""before string ops.Missing
returnbeforeResponse.redirect()— causes double-response errors. Alwaysreturn Response.redirect(...).No
.safeon user input in HTML — every untrusted string must be escaped, including in<input value="...">attributes.Forgetting CSRF on state-changing forms —
{{ session.csrf }}in the form,session.csrfOkin the handler.Plaintext passwords — use
Util.hash/Util.verify, never store raw passwords in the database.
Next Steps#
Security — CSRF, password hashing, session defaults, XSS
File Handling — uploading, serving, and managing files
Database — saving form data with parameterized queries
Advanced Routing — where to place form handlers with query params vs
_route.wren