Contents
- What are forms?
- How do they work?
- How do we build it?
- Summary
1. What are forms?
Forms are one of the fundamental tools through which we interact with the web everyday [1]. It enables us to sign into apps, signup for newsletters, purchase things online and post comments to list a few. They're a key mechanism through which we send information from our browser to the server.
In this tutorial, we're going to start with the simplest incarnation of forms and start to gain some appreciation for just how much functionality is built into the web platform. We'll go over how they work and how to build it. To follow along, you'll need a text editor, and a web server that can serve HTML files and handle requests. For my web server, I'm going to use Deno [2] and a web framework called Oak [3] to handle requests. On the frontend, it'll be plain HTML.
Prerequisites:
2. How do they work?
Before we dive into forms, let's start with what happens when you visit a website:
- You request for a website through the address bar or a link
- The server receives your request
- The server responds to your request by sending HTML as text
- Your browser renders that HTML
When you visit a site, the request that you make to the server is a GET request. The server needs to be configured to handle requests based on the path of the website being accessed and also what type of request is being made (i.e. GET, POST or any of the other HTTP verbs [4]). For example, when you access the site https://hasanhaja.com/picks
then you make a GET request to the path /picks
. Where as if you go to https://hasanhaja.com
then you make a GET request to the path /
.
With that context, let's follow an example where the HTML that is returned contains a form.
The form is denoted by the HTML tag <form>
and nested within it are fields for the data we want to collect along with a submit button. The default behavior of forms is the construct a search query with the form data and make another GET request to the same path.
The server can be configured to identify the search query parameters so that it can respond as instructed.
There's also another way forms can be configured on the frontend. If we'd like the form to submit data by making a POST request to the same path with the form data in the body of the request, we can add the method attribute to the HTML like this <form method="post">
. The server can be configured very similarly to handle this request.
In the above example, we only see the server return HTML as a response to the request, but in actuality it can be configured to perform any arbitrary action with the data. If this were a form to subscribe to a newsletter, then the server will perform the logic to add them to the email list and then send an HTML response to inform the user that the request was handled successfully.
If the form field were a search filter in an e-commerce website, then the form data can be used to query the database for all the products related to the search and then the response can be constructed with these results. The applications for forms are endless, and that's precisely why they're everywhere.
3. How do we build it?
Okay, let's build a form and start tinkering!
To keep things as simple as possible, our form will contain the minimum number of fields required for it to qualify as a form. Here's a form with one text field [5] called "name":
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Form</title>
</head>
<body>
<h1>Our form</h1>
<form>
<h2>Your details</h2>
<label for="name">Name</label>
<input id="name" name="name" type="text">
<button type="submit">Submit</button>
</form>
</body>
</html>
This is what the HTTP request looks like when you click submit:
GET /?name=Hasan HTTP/1.1
Host: localhost:8000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/114.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Referer: http://localhost:8000/
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
The HTML form constructed this entire HTTP request for us, and we only need to consume this on the server and send a response back. Let's look at the server code that can handle this form submission:
import { Application, Router } from "oak";
const router = new Router();
router
.get("/", async (context) => {
const { search } = context.request.url;
if (!search) {
await context.send({
root: `${Deno.cwd()}/static`,
index: "index.html",
});
return;
}
const params = new URLSearchParams(search);
const name = params.get("name");
context.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Form</title>
</head>
<body>
<h1>Our form</h1>
<p>Hi there, ${name}!</p>
</body>
</html>
`;
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
The .get
handler for the path "/"
handles both the request for the page and the form submission. The conditional check if (!search)
is to distinguish between the form being requested or the form being submitted. When the page is requested without the search query parameters, it means the user is requesting the form, and when it's requested with the query parameters it means the form was submitted. In our implementation when we encounter a form submission, we construct HTML inline and respond with the name in the body of the HTML.
The functionality of context.send
is to read the static HTML file from the server's filesystem and send it as text in the response. The Oak framework constructs an HTTP response with the appropriate headers to tell the browser what kind of content it will be rendering. You can debug the response headers in the Network tab of the browser's developer tools, and this is what it looks like for our form submission response:
HTTP/1.1 200 OK
content-type: text/html; charset=UTF-8
vary: Accept-Encoding
content-encoding: gzip
content-length: 187
date: Mon, 10 Jul 2023 22:05:42 GMT
Aside: The
content-type: text/html
is why responding with an inlined HTML string by setting thecontext.response.body
is both valid and equivalent to thecontext.send
approach. Thecontext
is an implementation detail specific to the Oak framework, and your mileage may vary depending on how you handle HTTP requests.
This can get a little confusing on the server. It's confusing because when the form data is sent to the server, the underlying HTTP verb is still GET. That means when the server receives a GET request, it also needs to check if form data is being sent to it. Using GET with data is a perfectly valid pattern, but we can make our lives a little easier when dealing with forms to separate the logic for getting forms and posting data.
To switch this form to send the form data via POST, we can add the "method" attribute with the value "post" to the form
tag.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Form</title>
</head>
<body>
<h1>Our form</h1>
<form method="post">
<h2>Your details</h2>
<label for="name">Name</label>
<input id="name" name="name" type="text">
<button type="submit">Submit</button>
</form>
</body>
</html>
This seems too trivial and it is. The web platform is incredibly powerful and feature-rich. This little declaration entirely changed the communication mechanism. This is the HTTP request that gets sent to the server:
POST / HTTP/1.1
Host: localhost:8000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/114.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 10
Origin: http://localhost:8000
Connection: keep-alive
Referer: http://localhost:8000/
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
And this is the payload that goes with the request:
name=Hasan
On the server, we can handle this by adding another handler to our router
:
/* --snip-- */
router
.get("/", async (context) => {
const { search } = context.request.url;
if (!search) {
await context.send({
root: `${Deno.cwd()}/static`,
index: "index.html",
});
return;
}
/* --snip-- */
})
.post("/", async (context) => {
const formBody = await context.request.body({ type: "form" }).value;
const name = formBody.get("name");
context.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Form</title>
</head>
<body>
<h1>Our form</h1>
<p>Hi there, ${name}!</p>
</body>
</html>
`;
})
;
/* --snip-- */
We'll leave the .get
handler as is so it can still serve the form to us, but when we submit it our .post
handler will handle that request. And that's how simple forms are!
This is our entire server code that handles form submissions through the GET and POST method:
import { Application, Router } from "oak";
const router = new Router();
router
.get("/", async (context) => {
const { search } = context.request.url;
if (!search) {
await context.send({
root: `${Deno.cwd()}/static`,
index: "index.html",
});
return;
}
const params = new URLSearchParams(search);
const name = params.get("name");
context.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Form</title>
</head>
<body>
<h1>Our form</h1>
<p>Hi there, ${name}!</p>
</body>
</html>
`;
})
.post("/", async (context) => {
const formBody = await context.request.body({ type: "form" }).value;
const name = formBody.get("name");
context.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Form</title>
</head>
<body>
<h1>Our form</h1>
<p>Hi there, ${name}!</p>
</body>
</html>
`;
})
;
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
All the source code for this post can also be found on my GitHub.
Summary
Forms are simple and they are deeply supported in the web platform. They enable us to capture data from users and this provides an interactive web experience. In this post, we looked at the anatomy of HTML form and how to build them. We saw two methods of submitting form data to the server and we saw that although the difference in code is very minimal, the communication of intent is far stronger with the POST method.
HTML forms have a lot of features packed into them. There are a lot more fun and complex situations that require delving into them deeply. Examples of this can be:
- Validating the input to ensure it's in the correct format before sending it to the server
- Validating the data on the server and responding with an error message if the validations fail
Stay tuned for follow up posts in this web fundamentals series.
If you think of anything I've missed or just wanted to get in touch, you can reach me through a comment, via Mastodon, via Threads, via Twitter or through LinkedIn.
Top comments (0)