Forms
Marko renders HTML, and HTML forms work without any JavaScript. Starting from a native <form> keeps submissions functional before scripts load, and Marko's reactivity can then layer on richer behavior where it helps.
Server Submission
In a Marko Run app, a form posts to a route, and a +handler beside the page receives the submission. The handler reads the submitted fields with the standard FormData API and redirects when finished.
<h1>Send Feedback</h1>
<form method="POST">
<label for="message">Message</label>
<textarea id="message" name="message" required/>
<button type="submit">Send</button>
</form><h1>Send Feedback</h1>
<form method="POST">
<label for="message">Message</label>
<textarea id="message" name="message" required/>
<button type="submit">Send</button>
</form>h1 -- Send Feedback
form method="POST"
label for="message" -- Message
textarea id="message" name="message" required
button type="submit" -- Sendh1 -- Send Feedback
form method="POST"
label for="message" -- Message
textarea id="message" name="message" required
button type="submit" -- Sendexport async function POST(context) {
const data = await context.request.formData();
const message = data.get("message");
if (typeof message !== "string" || !message.trim()) {
return new Response("A message is required.", { status: 400 });
}
await saveFeedback(message);
return context.redirect("/feedback/thanks", 303);
}GET requests continue to the page as usual, while the POST export handles submissions from the form above.
Redirecting after a successful POST (the Post/Redirect/Get pattern) prevents a page refresh from resubmitting the form. See Preventing Duplicate Form Submissions for making repeated requests safe on the server.
Validating
Native validation attributes such as required, min, and pattern block invalid submissions in the browser without any additional code.
<form method="POST">
<label for="age">Age</label>
<input id="age" name="age" type="number" min="18" required>
<button type="submit">Continue</button>
</form><form method="POST">
<label for="age">Age</label>
<input id="age" name="age" type="number" min="18" required>
<button type="submit">Continue</button>
</form>form method="POST"
label for="age" -- Age
input id="age" name="age" type="number" min="18" required
button type="submit" -- Continueform method="POST"
label for="age" -- Age
input id="age" name="age" type="number" min="18" required
button type="submit" -- ContinueBrowser validation is a convenience, not a boundary. Requests can be constructed without the form, so handlers must validate submitted values again on the server.
Reacting to Input
Binding form controls to tag variables enables live behavior such as previews, character counts, or dependent fields. The change handler shorthand (:=) keeps a variable in sync with a control.
<let/message="">
<form method="POST">
<label for="message">Message</label>
<textarea id="message" name="message" maxlength="280" value:=message/>
<p>${280 - message.length} characters left</p>
<button type="submit">Send</button>
</form><let/message="">
<form method="POST">
<label for="message">Message</label>
<textarea id="message" name="message" maxlength="280" value:=message/>
<p>${280 - message.length} characters left</p>
<button type="submit">Send</button>
</form>let/message=""
form method="POST"
label for="message" -- Message
textarea id="message" name="message" maxlength="280" value:=message
p -- ${280 - message.length} characters left
button type="submit" -- Sendlet/message=""
form method="POST"
label for="message" -- Message
textarea id="message" name="message" maxlength="280" value:=message
p -- ${280 - message.length} characters left
button type="submit" -- SendBecause the form still posts natively, this enhancement degrades gracefully: without JavaScript the character counter stays static, but the form submits all the same.
Deriving disabled on the submit button from bound state (for example, disabling it until a field is filled in) renders the button disabled in the server HTML, locking out visitors whose JavaScript has not loaded. Prefer validation attributes, which the browser enforces on its own.
Binding Controls
Every stateful form control has a Change handler in Marko, so the := shorthand from the previous section is not limited to text. Radio groups and checkboxes bind through the checkedValue attribute, which holds a string for radios and an array for checkbox groups, and <select> binds through its enhanced value attribute.
<let/format="html">
<let/topics=[]>
<let/frequency="weekly">
<form method="POST" action="/newsletter">
<fieldset>
<legend>Format</legend>
<label>
<input type="radio" name="format" value="html" checkedValue:=format>
HTML
</label>
<label>
<input type="radio" name="format" value="text" checkedValue:=format>
Plain text
</label>
</fieldset>
<fieldset>
<legend>Topics</legend>
<label>
<input
type="checkbox"
name="topics"
value="releases"
checkedValue:=topics
>
Releases
</label>
<label>
<input
type="checkbox"
name="topics"
value="community"
checkedValue:=topics
>
Community
</label>
</fieldset>
<label for="frequency">Frequency</label>
<select id="frequency" name="frequency" value:=frequency>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<p>
${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
${frequency}.
</p>
<button type="submit">Subscribe</button>
</form><let/format="html">
<let/topics=[]>
<let/frequency="weekly">
<form method="POST" action="/newsletter">
<fieldset>
<legend>Format</legend>
<label>
<input type="radio" name="format" value="html" checkedValue:=format>
${" "}HTML
</label>
<label>
<input type="radio" name="format" value="text" checkedValue:=format>
${" "}Plain text
</label>
</fieldset>
<fieldset>
<legend>Topics</legend>
<label>
<input
type="checkbox"
name="topics"
value="releases"
checkedValue:=topics
>
${" "}Releases
</label>
<label>
<input
type="checkbox"
name="topics"
value="community"
checkedValue:=topics
>
${" "}Community
</label>
</fieldset>
<label for="frequency">Frequency</label>
<select id="frequency" name="frequency" value:=frequency>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<p>
${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
${frequency}.
</p>
<button type="submit">Subscribe</button>
</form>let/format="html"
let/topics=[]
let/frequency="weekly"
form method="POST" action="/newsletter"
fieldset
legend -- Format
label
input type="radio" name="format" value="html" checkedValue:=format
-- HTML
label
input type="radio" name="format" value="text" checkedValue:=format
-- Plain text
fieldset
legend -- Topics
label
input type="checkbox" name="topics" value="releases" checkedValue:=topics
-- Releases
label
input type="checkbox" name="topics" value="community" checkedValue:=topics
-- Community
label for="frequency" -- Frequency
select id="frequency" name="frequency" value:=frequency
option value="daily" -- Daily
option value="weekly" -- Weekly
option value="monthly" -- Monthly
p --
${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
${frequency}.
button type="submit" -- Subscribelet/format="html"
let/topics=[]
let/frequency="weekly"
form method="POST" action="/newsletter"
fieldset
legend -- Format
label
input type="radio" name="format" value="html" checkedValue:=format
-- ${" "}HTML
label
input type="radio" name="format" value="text" checkedValue:=format
-- ${" "}Plain text
fieldset
legend -- Topics
label
input type="checkbox" name="topics" value="releases" checkedValue:=topics
-- ${" "}Releases
label
input type="checkbox" name="topics" value="community" checkedValue:=topics
-- ${" "}Community
label for="frequency" -- Frequency
select id="frequency" name="frequency" value:=frequency
option value="daily" -- Daily
option value="weekly" -- Weekly
option value="monthly" -- Monthly
p --
${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered
${frequency}.
button type="submit" -- SubscribeSuccessful, named controls still submit their values natively; the bound variables exist so the rest of the template can react, as the summary line does here.
value bindings report strings. Binding a numeric input directly (value:=quantity) turns the variable into a string on the first edit. In contrast, checked bindings produce booleans, while checkedValue may produce an array for checkbox groups. Add a refining function to convert each value change before it is assigned:
<let/quantity=1>
<input type="number" name="quantity" min="1" value:parseFloat:=quantity><let/quantity=1>
<input type="number" name="quantity" min="1" value:parseFloat:=quantity>let/quantity=1
input type="number" name="quantity" min="1" value:parseFloat:=quantitylet/quantity=1
input type="number" name="quantity" min="1" value:parseFloat:=quantityReusable Fields
Repeated label-and-input markup can move into a custom tag, discovered from a tags/ directory. The <id> tag generates a unique id per instance to associate the label with its control, and binding the native input to input.value forwards both the value and its change handler to the parent, so the tag is bound with := exactly like a native control.
<id/fieldId>
<div class="field">
<label for=fieldId>${input.label}</label>
<input id=fieldId name=input.name value:=input.value>
</div><id/fieldId>
<div class="field">
<label for=fieldId>${input.label}</label>
<input id=fieldId name=input.name value:=input.value>
</div>id/fieldId
div class="field"
label for=fieldId -- ${input.label}
input id=fieldId name=input.name value:=input.valueid/fieldId
div class="field"
label for=fieldId -- ${input.label}
input id=fieldId name=input.name value:=input.value<let/displayName="">
<form method="POST">
<labeled-input label="Display name" name="displayName" value:=displayName/>
<p hidden=!displayName>Previewing as ${displayName}</p>
<button type="submit">Save</button>
</form><let/displayName="">
<form method="POST">
<labeled-input label="Display name" name="displayName" value:=displayName/>
<p hidden=!displayName>Previewing as ${displayName}</p>
<button type="submit">Save</button>
</form>let/displayName=""
form method="POST"
labeled-input label="Display name" name="displayName" value:=displayName
p hidden=!displayName -- Previewing as ${displayName}
button type="submit" -- Savelet/displayName=""
form method="POST"
labeled-input label="Display name" name="displayName" value:=displayName
p hidden=!displayName -- Previewing as ${displayName}
button type="submit" -- SaveComponents that hold their own state can offer the same interface by making that state controllable; Controllable Components covers the pattern in depth.
Contributors
Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.