Forms are the way people tell a website something: a search, a sign-up, an appointment booking. An HTML form works entirely without JavaScript. The browser collects the fields, checks the simplest rules and sends everything to the server with one HTTP request. This is exactly how the Kladde builds its public appointment booking: It works in every browser, even if a script doesn’t load, and script at most makes it more convenient later (module 2.2).

In this lesson you build the third page of the prototype, appointment.html, and send it to a small server that shows you what arrives.

The form element

HTML
<form action="/book" method="post">
  <label for="name">Name</label>
  <input type="text" id="name" name="name" required>
  <button type="submit">Request appointment</button>
</form>
  • action is the address the browser sends the data to. If it’s missing, the data goes to the address of the page itself.
  • method is get or post. If it’s missing, get applies.
  • A field is only sent if it has a name attribute. The name is the key under which the server finds the value. The id, on the other hand, is needed only by the page itself, for example for the label.
  • button type="submit" submits the form. So does the Enter key in a text field. Always write out type: A button without type inside a form is a submit button, even if it was meant to do something else.

Labels

Every field needs a visible label, a label. The label’s for names the field’s id. This brings three things:

  • When entering the field, a screen reader reads the label aloud: “Name, edit text, required”.
  • A click on the label puts the focus in the field. For small checkboxes and radio buttons that is a much bigger click target.
  • Voice control works: “Click Name”.

The placeholder (placeholder) is not a substitute for a label. It disappears while typing, is usually pale and hard to read, and anyone who scrolls back no longer knows what belonged in the field. A hint for the field stands as a text of its own below it and is connected with aria-describedby, so that a screen reader reads it after the label:

HTML
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email" required aria-describedby="email-hint">
<p id="email-hint">We’ll send the confirmation to this address.</p>

Input types

The type attribute tells the browser what kind of value is expected. On the phone you then get the matching keyboard, and the browser checks simple formats itself:

Element or type What for What the browser does
type="text" free text, such as a name nothing special
type="email" an email address keyboard with @, roughly checks the format
type="tel" a phone number number keyboard, checks nothing, because formats differ around the world
type="number" a quantity you calculate with arrows to count up and down; not for ZIP codes or phone numbers, which keep their leading zeros
type="date", type="time" date, time a picker in a calendar; what’s sent is always YYYY-MM-DD and HH:MM, however it’s displayed
type="checkbox" yes or no, several out of many sent only if checked
type="radio" exactly one out of a few all with the same name form a group
type="password" a password shows dots instead of characters
select with option one out of many a drop-down list
textarea longer text a multi-line field

So a date always arrives at the server the same way, even if a US browser displays 12/02/2025 and a German one 02.12.2025.

Required fields and limits

With attributes you tell the browser what is valid:

  • required: The field must not be empty.
  • minlength, maxlength: length of text.
  • min, max, step: limits for numbers, dates and times.
  • pattern: a regular expression that the whole value must satisfy. Use it sparingly. A pattern that’s too strict for names or phone numbers locks out real people.

If a value is invalid, the browser doesn’t submit the form, puts the focus on the first faulty field and shows a short message. Its wording and look depend on the browser.

This is a convenience, not security. Anyone can send a server whatever they like: with curl, with another program or with a form from which they’ve deleted the required in the browser. So the server checks every input again itself. In the Kladde, validation at the edge takes care of that from module 2.3 on, and domain rules like “no double booking” are checked by the model.

With the attribute novalidate on the form you switch off the browser’s check. You need that for exercise 2, to see what the server does with empty fields.

autocomplete: the browser helps fill in

With autocomplete you tell the browser what information a field expects. It then offers to fill it in from what it has stored. That saves everyone typing and helps especially people for whom typing is hard. The most important values:

Value Meaning
name full name
email email address
tel phone number
organization company
street-address, postal-code, address-level2 street, ZIP code, city
current-password, new-password password when signing in and when creating one
off don’t fill in; only for fields where a stored value would be wrong, such as a one-time code

The values are fixed; no browser understands autocomplete="e-mail". For information about the person filling in the form, the accessibility rules even require this labeling (lesson 2.1.7).

Groups with fieldset and legend

If several fields belong together, put them in a fieldset with a legend as its heading. For radio buttons this is a must for comprehension: Without a group you hear only “Advice, radio button, one of two” and don’t know what’s being asked. With a group you hear the question first.

HTML
<fieldset>
  <legend>What is it about?</legend>
  <div>
    <input type="radio" id="topic-advice" name="topic" value="advice" checked>
    <label for="topic-advice">Advice on a project</label>
  </div>
  <div>
    <input type="radio" id="topic-help" name="topic" value="help">
    <label for="topic-help">Help with a problem</label>
  </div>
</fieldset>

Both radio buttons have the same name, so exactly one value belongs to the key topic. What’s sent is the value of the selected one, not its label. The checked preselects an option so that the group is never submitted empty.

The form server

So that you can see what arrives at the server, there is a second practice server. It serves the files of your prototype, so that the pages appear in the browser at http://localhost:5080/, and has four endpoints:

  • /echo shows every received field as text, for GET and POST;
  • POST /book checks the required fields, books and redirects with 303 to the confirmation;
  • POST /book-direct books without checking and answers immediately with 200, only for comparison in exercise 3;
  • GET /bookings/{number} shows a booking.

Create form-server.cs in your practice folder k2-practice:

C#
// form-server.cs: serves the prototype and shows what a form sends
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

using System.Text;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;

string folder = Path.GetFullPath(args.Length > 0 ? args[0] : "prototype");
if (!Directory.Exists(folder))
{
    Console.WriteLine($"The folder {folder} doesn't exist.");
    return 2;
}

var builder = WebApplication.CreateBuilder();
builder.Logging.SetMinimumLevel(LogLevel.Warning);
var app = builder.Build();

var files = new PhysicalFileProvider(folder);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
app.UseStaticFiles(new StaticFileOptions { FileProvider = files });

// Shows every field that arrives, as text.
app.MapMethods("/echo", ["GET", "POST"], async (HttpRequest request) =>
{
    var text = new StringBuilder();
    text.AppendLine($"Method: {request.Method}");
    text.AppendLine($"Content-Type: {request.ContentType ?? "(none)"}");
    text.AppendLine($"Query: {(request.QueryString.HasValue ? request.QueryString.Value : "(none)")}");
    IEnumerable<KeyValuePair<string, StringValues>> fields =
        request.HasFormContentType ? await request.ReadFormAsync() : request.Query;
    foreach (var (name, values) in fields)
    {
        foreach (string? value in values)
        {
            text.AppendLine($"{name} = {(string.IsNullOrEmpty(value) ? "(empty)" : value)}");
        }
    }

    return Results.Text(text.ToString(), "text/plain; charset=utf-8");
});

List<string> bookings = [];
string[] required = ["name", "email", "day", "time"];

int Book(IFormCollection form)
{
    lock (bookings)
    {
        bookings.Add($"{form["name"]} <{form["email"]}>, {form["day"]} at {form["time"]}");
        return bookings.Count;
    }
}

// Checks the required fields, books and redirects with 303 to the confirmation (Post-Redirect-Get).
app.MapPost("/book", async (HttpRequest request, HttpResponse response) =>
{
    var form = await request.ReadFormAsync();
    string[] missing = [.. required.Where(field => string.IsNullOrWhiteSpace(form[field]))];
    if (missing.Length > 0)
    {
        return Results.Text($"Missing: {string.Join(", ", missing)}", "text/plain; charset=utf-8", statusCode: 400);
    }

    int id = Book(form);
    response.Headers.Location = $"/bookings/{id}";
    return Results.StatusCode(StatusCodes.Status303SeeOther);
});

// Books without checking and answers at once with 200, no redirect: for comparison, not like this.
app.MapPost("/book-direct", async (HttpRequest request) =>
{
    int id = Book(await request.ReadFormAsync());
    return Results.Text($"Booked as number {id}. So far {bookings.Count} booking(s).", "text/plain; charset=utf-8");
});

app.MapGet("/bookings/{id:int}", (int id) =>
    id >= 1 && id <= bookings.Count
        ? Results.Text($"Booking {id}: {bookings[id - 1]}. So far {bookings.Count} booking(s).", "text/plain; charset=utf-8")
        : Results.NotFound());

Console.WriteLine($"The form server is serving {folder} at http://localhost:5080. Quit with Ctrl+C.");
app.Run("http://localhost:5080");
return 0;

A few things in it you know from lesson 2.1.1; new are:

  • UseDefaultFiles and UseStaticFiles serve files from a folder. The first makes sure that / shows the file index.html.
  • request.ReadFormAsync() reads the fields of a submitted form, request.Query the fields of a query.
  • There’s no ready-made method for 303 in minimal APIs; the endpoint sets Location itself and answers with the status code.
  • As in lesson 2.1.1, the server forgets everything when it quits.

Start it with the folder of your prototype as the argument (adjust the path if your Kladde is somewhere else):

Bash
dotnet run form-server.cs -- ../kladde/prototype
The form server is serving /home/ada/kladde/prototype at http://localhost:5080. Quit with Ctrl+C.

On your machine, the full path of your folder is shown there. Open http://localhost:5080/ in the browser: The prototype’s home page appears, and the links work like on a real server.

What arrives at the server: GET and POST

If a form submits with method="get", the browser appends the fields to the address as a query. You can reproduce the same with curl:

Bash
curl "http://localhost:5080/echo?topic=help&day=&time=09%3A00&name=Ada+Sample"
Method: GET
Content-Type: (none)
Query: ?topic=help&day=&time=09%3A00&name=Ada+Sample
topic = help
day = (empty)
time = 09:00
name = Ada Sample

A form encodes spaces as + and the colon as %3A; the server turns that back into the value. An empty field is sent along, just empty.

With method="post", the same fields are in the body of the request, in the format application/x-www-form-urlencoded, and the address stays clean. --data-urlencode sends them the way a browser would:

Bash
curl --data-urlencode "topic=advice" --data-urlencode "day=2025-12-02" --data-urlencode "time=10:00" --data-urlencode "name=Ada Sample" --data-urlencode "email=ada@example.com" --data-urlencode "phone=" --data-urlencode "message=It's about Feld & Partner." http://localhost:5080/echo
Method: POST
Content-Type: application/x-www-form-urlencoded
Query: (none)
topic = advice
day = 2025-12-02
time = 10:00
name = Ada Sample
email = ada@example.com
phone = (empty)
message = It's about Feld & Partner.

When to use which method?

  • GET for everything that only reads: a search, a filter, a page of a list. The result has an address of its own; you can save it as a bookmark, share it and find it again with “Back”. The Kladde’s contact search will be such a form (module 2.2).
  • POST for everything that changes or creates something: a booking, a sign-up, a message. And for everything that doesn’t belong in an address: Addresses end up in the browser’s history, in bookmarks and in the logs of servers. Passwords and personal details have no business there.

A third format you know here only by name: Anyone who uploads files needs enctype="multipart/form-data" on the form. Then each field arrives as a part of its own.

Error messages at the field

If the server checks an input and finds an error, it sends back the page with the form: with everything the person has already entered, and with a message at the field. A good error page has four features:

  1. The title begins with “Error:”, so that a screen reader says it right when the page loads.
  2. At the top is a list of all the errors, each one a link to its field.
  3. At the field, the message is there as text. It says what to do, not just what’s wrong: “The email address needs an @, for example name@example.com” instead of “Invalid input”.
  4. The field carries aria-invalid="true" and points with aria-describedby to its message. A screen reader then reads “Email, invalid entry, The email address needs an @ …”.

This is what the field looks like in the error case:

HTML
<label for="email">Email</label>
<input type="email" id="email" name="email" value="ada.example.com" autocomplete="email" required
       aria-invalid="true" aria-describedby="email-error email-hint">
<p id="email-error">The email address needs an @, for example name@example.com.</p>
<p id="email-hint">We'll send the confirmation to this address.</p>

aria-describedby may name several id values, separated by spaces; the screen reader reads them in that order. And a color alone is not an error message: A field with a red outline but no text is overlooked by anyone who doesn’t see red. In lesson 2.1.4 the message additionally gets a color, and in module 2.5 Blazor generates this page for you.

Post-Redirect-Get

What happens if the server answers a submitted form directly with 200 and a confirmation page? The page in the browser is then the result of a POST. If the person reloads it, say because the network was briefly gone, the browser asks “Resubmit form?”. Anyone who clicks “Yes” books a second time. “Back” and “Forward” can trigger that too.

The Post-Redirect-Get pattern prevents that:

Browser                                   Server
  POST /book  (name, email, day, time)      ──▶  checks, books as number 7
                                            ◀──  303 See Other, Location: /bookings/7
  GET /bookings/7                           ──▶  reads booking 7
                                            ◀──  200 OK, the confirmation

After the POST, the server answers only with a redirect. The browser fetches the confirmation with GET, and the page in the browser is now the result of this GET. Reloading fetches the confirmation again but books nothing. With curl you see both steps if you add -L:

Bash
curl -i -L --data-urlencode "name=Ilse Kranich" --data-urlencode "email=ilse.kranich@example.net" --data-urlencode "day=2025-12-03" --data-urlencode "time=14:00" http://localhost:5080/book
HTTP/1.1 303 See Other
Content-Length: 0
Location: /bookings/1

HTTP/1.1 200 OK
Content-Length: 93
Content-Type: text/plain; charset=utf-8

Booking 1: Ilse Kranich <ilse.kranich@example.net>, 2025-12-03 at 14:00. So far 1 booking(s).

As in lesson 2.1.1, the Date and Server lines are left out, and the number depends on how often you’ve already booked.

A stumbling block with curl: Do not add -X POST here. -X forces curl to follow the redirect with POST too, and after a second booking you get 405 Method Not Allowed. Without -X, curl turns a -d or --data-urlencode into a POST on its own and follows the 303 with GET, like a browser.

If the server answers with an error, on the other hand, such as 400 because fields are missing, it does not redirect: The error page isn’t a result you’d want to reload, and nothing was booked, after all.

Exercise 1: The booking page and its echo

Build prototype/appointment.html with the same header and footer as the other pages (aria-current="page" now on “Book an appointment”), the title “Book an appointment – Kladde”, the heading “Book an appointment” and a sentence saying that an appointment lasts one hour. Below it, a form with action="/echo" and method="post" and these fields:

  • a group “What is it about?” with two options: Advice on a project (preselected) and Help with a problem;
  • a group “When?” with the day (date, required) and the time (a choice of 09:00, 10:00, 11:00, 14:00, 15:00 and 16:00, required, with an empty first option “Please choose”);
  • a group “Your details” with name (required, at most 100 characters), email (required, with the hint “We’ll send the confirmation to this address.”), phone (optional) and message (optional, at most 1000 characters);
  • a checkbox “Remind me by email the day before the appointment”;
  • a button “Request appointment”.

Fields that aren’t required carry the addition “(optional)” in their label. Name, email and phone get the fitting autocomplete.

Start the form server with your prototype and open http://localhost:5080/appointment.html. Before you submit: Write down which lines the echo will show if you fill in everything but don’t check the checkbox. Then submit and compare. Repeat it with the checkbox checked, and finally with method="get": What’s in the address bar now?

The exercise is checked when the validator reports nothing for appointment.html, outline.cs finds no problems and your prediction of the echo was right or you can explain every deviation.

Solution 1

The form, here already with action="/book", as it stands in the Kladde at the end of the lesson. For this exercise it still says /echo.

HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Book an appointment – Kladde</title>
</head>
<body>
  <header>
    <a href="index.html"><img src="logo.svg" alt="Kladde, home page" width="48" height="48"></a>
    <nav aria-label="Main navigation">
      <ul>
        <li><a href="index.html">Home</a></li>
        <li><a href="contacts.html">Contacts</a></li>
        <li><a href="appointment.html" aria-current="page">Book an appointment</a></li>
      </ul>
    </nav>
  </header>
  <main>
    <h1>Book an appointment</h1>
    <p>An appointment lasts one hour. You have to fill in the fields without the addition “optional”.</p>
    <form action="/book" method="post">
      <fieldset>
        <legend>What is it about?</legend>
        <div>
          <input type="radio" id="topic-advice" name="topic" value="advice" checked>
          <label for="topic-advice">Advice on a project</label>
        </div>
        <div>
          <input type="radio" id="topic-help" name="topic" value="help">
          <label for="topic-help">Help with a problem</label>
        </div>
      </fieldset>
      <fieldset>
        <legend>When?</legend>
        <div>
          <label for="day">Day</label>
          <input type="date" id="day" name="day" required>
        </div>
        <div>
          <label for="time">Time</label>
          <select id="time" name="time" required>
            <option value="">Please choose</option>
            <option>09:00</option>
            <option>10:00</option>
            <option>11:00</option>
            <option>14:00</option>
            <option>15:00</option>
            <option>16:00</option>
          </select>
        </div>
      </fieldset>
      <fieldset>
        <legend>Your details</legend>
        <div>
          <label for="name">Name</label>
          <input type="text" id="name" name="name" autocomplete="name" required maxlength="100">
        </div>
        <div>
          <label for="email">Email</label>
          <input type="email" id="email" name="email" autocomplete="email" required aria-describedby="email-hint">
          <p id="email-hint">We'll send the confirmation to this address.</p>
        </div>
        <div>
          <label for="phone">Phone (optional)</label>
          <input type="tel" id="phone" name="phone" autocomplete="tel">
        </div>
        <div>
          <label for="message">Message (optional)</label>
          <textarea id="message" name="message" rows="4" maxlength="1000"></textarea>
        </div>
      </fieldset>
      <div>
        <input type="checkbox" id="reminder" name="reminder" value="yes">
        <label for="reminder">Remind me by email the day before the appointment</label>
      </div>
      <button type="submit">Request appointment</button>
    </form>
  </main>
  <footer>
    <p>Kladde, a practice project. All people and companies are made up.</p>
  </footer>
</body>
</html>

The echo without the checkbox checked, with empty optional fields (your date will be a different one):

Method: POST
Content-Type: application/x-www-form-urlencoded
Query: (none)
topic = advice
day = 2025-12-02
time = 10:00
name = Ada Sample
email = ada@example.com
phone = (empty)
message = (empty)

The most common deviations from the prediction:

  • The checkbox is missing entirely, instead of showing up with an empty value. An unchecked checkbox sends nothing. With a check, the line reminder = yes appears at the end, so the value, not the label. For the server, “not there” therefore means “no”.
  • The button doesn’t appear. It has no name, so it sends nothing. If you gave it one, it would come along too.
  • topic is advice, not “Advice on a project”: What’s sent is the value.
  • The date arrives as YYYY-MM-DD, however the browser displays it.
  • Empty optional fields come along, just empty.

With method="get", everything is in the address bar, for example http://localhost:5080/echo?topic=advice&day=2025-12-02&time=10%3A00&name=Ada+Sample&email=ada%40example.com&phone=&message=. That’s exactly why get is wrong for a booking: Name and email address would be in the browser’s history and in every server log.

Exercise 2: Submitting required fields empty

Let the form still send to /echo and proceed like this:

  1. Leave name and email empty and click “Request appointment”. What happens, and where does the focus jump?
  2. Write ada.example.com in the email field and fill in everything else. What happens?
  3. Put novalidate on the form element and submit with an empty name and an empty email. What does the echo show?
  4. Change action to /book and submit again with empty fields. Which status code do you get? Check it with curl too, entirely without a browser.
  5. Take novalidate out again.

For each step, note in one sentence who found the error: the browser or the server.

Solution 2

  1. The browser sends nothing. It puts the focus in the first empty required field and shows a short message there. No request arrives at the server.

  2. Again the browser: type="email" demands an @. Check it: In the server’s terminal and in the echo, nothing happens.

  3. With novalidate, the browser checks nothing, and the echo shows name = (empty) and email = (empty). That’s the situation every server has to be ready for: It gets what is sent, not what the form asked for.

  4. The server answers with 400 and the text Missing: name, email. With curl:

    Bash
    curl -i --data-urlencode "name=" --data-urlencode "email=ada@example.com" http://localhost:5080/book
    
    HTTP/1.1 400 Bad Request
    Content-Length: 24
    Content-Type: text/plain; charset=utf-8
    
    Missing: name, day, time
    

    The request doesn’t send day and time at all, and the server counts them as missing too. So it checks not only empty but also missing fields, and that’s what matters, because a home-made client doesn’t stick to any form.

  5. Without novalidate, the browser checks again before submitting. Both together are right: the browser for convenience, the server for security.

Exercise 3: Experiencing Post-Redirect-Get

Restart the form server so that it begins with zero bookings.

  1. Set action to /book-direct and book an appointment. The browser shows “Booked as number 1. So far 1 booking(s).” Now reload the page with F5 and confirm the browser’s prompt. What does it say?
  2. Set action to /book and book again. Which address is in the address bar afterward? Reload: Does a prompt appear, and does the number of bookings change?
  3. Leave action="/book" in place; that’s how it stays in the Kladde.

Solution 3

  1. After reloading, it says “Booked as number 2. So far 2 booking(s).” The browser repeated the POST, and the server booked a second time. The address bar still says /book-direct. A person who reloads twice on a bad network would have three appointments.
  2. The address bar shows /bookings/3, and there it says “Booking 3: … So far 3 booking(s).” Reloading doesn’t ask and changes nothing: The browser merely repeats the GET of the confirmation, which is safe and idempotent (lesson 2.1.1).

Post-Redirect-Get doesn’t make submitting idempotent: Anyone who submits the form a second time themselves books a second time. It only prevents reloading and going back from doing so unnoticed. Against double bookings, a rule in the model and in the database helps in module 2.4.

Exercise 4: The page in the error case

Build prototype/appointment-error.html: This is what the booking page should look like when the server has found two errors, a missing time and an email address without @. For this you need only the groups “When?” and “Your details” with day, time, name and email. Follow the four features of a good error page from the section “Error messages at the field”. The person’s entries stand as value in the fields. Check with the validator and outline.cs, and click the links of the error list in the browser: Does the focus jump into the field each time?

This page is a template for later: In lesson 2.1.4 it gets its colors, in module 2.5 Blazor generates it.

Solution 4

HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Error: Book an appointment – Kladde</title>
</head>
<body>
  <header>
    <a href="index.html"><img src="logo.svg" alt="Kladde, home page" width="48" height="48"></a>
    <nav aria-label="Main navigation">
      <ul>
        <li><a href="index.html">Home</a></li>
        <li><a href="contacts.html">Contacts</a></li>
        <li><a href="appointment.html" aria-current="page">Book an appointment</a></li>
      </ul>
    </nav>
  </header>
  <main>
    <h1>Book an appointment</h1>
    <div>
      <h2>Please check two details</h2>
      <ul>
        <li><a href="#time">Choose a time.</a></li>
        <li><a href="#email">The email address needs an @, for example name@example.com.</a></li>
      </ul>
    </div>
    <form action="/book" method="post">
      <fieldset>
        <legend>When?</legend>
        <div>
          <label for="day">Day</label>
          <input type="date" id="day" name="day" value="2025-12-02" required>
        </div>
        <div>
          <label for="time">Time</label>
          <select id="time" name="time" required aria-invalid="true" aria-describedby="time-error">
            <option value="">Please choose</option>
            <option>09:00</option>
            <option>10:00</option>
          </select>
          <p id="time-error">Choose a time.</p>
        </div>
      </fieldset>
      <fieldset>
        <legend>Your details</legend>
        <div>
          <label for="name">Name</label>
          <input type="text" id="name" name="name" value="Ada Sample" autocomplete="name" required maxlength="100">
        </div>
        <div>
          <label for="email">Email</label>
          <input type="email" id="email" name="email" value="ada.example.com" autocomplete="email" required
                 aria-invalid="true" aria-describedby="email-error email-hint">
          <p id="email-error">The email address needs an @, for example name@example.com.</p>
          <p id="email-hint">We'll send the confirmation to this address.</p>
        </div>
      </fieldset>
      <button type="submit">Request appointment</button>
    </form>
  </main>
</body>
</html>

The validator reports nothing, and outline.cs shows the outline h1 Book an appointment, below it h2 Please check two details. The links of the error list point to the id of the fields; a click jumps there and puts the focus in the field. The message in the list and at the field is worded identically, so that nobody has to compare two texts. The list of times is shortened here; on the real page all the times are there.

The Kladde: the booking page

Commit prototype/appointment.html with action="/book" and prototype/appointment-error.html on a branch of their own, for example prototype-booking, and open a pull request. In the description, write how you checked: validator, outline.cs, a run through the booking with the form server up to the confirmation, a reload without a second booking.

Do not commit the form server; it’s a practice tool from your folder k2-practice. If you open the prototype without the server with a double click, “Request appointment” leads nowhere. Only in module 2.5 does the Kladde get its own endpoint for the booking.

What you can do now

  • You build a form with action, method, labels and fitting input types.
  • You set required, lengths and autocomplete and group with fieldset and legend.
  • You know that the browser checks only for convenience and that the server has to check every input itself.
  • You choose GET for anything that reads and has an address of its own, and POST for anything that changes something or doesn’t belong in an address.
  • You mark up errors at the field with text, aria-invalid and aria-describedby.
  • You apply Post-Redirect-Get and can explain what it protects against and what it doesn’t.

Read more