Forms are an essential component of any web application. While there are many libraries and frameworks that help with form creation, input validation, data parsing, user feedback, etc., they often introduce unnecessary overhead and complexity.
This series of tutorials offers a step-by-step guide for building robust and user-friendly forms using plain HTML, CSS, and JavaScript. In this series, we’ll build an "Account Settings" form for a web application, following these steps:
This tutorial assumes familiarity with HTML, CSS, JavaScript and Node.js. The finished code for this tutorial can be found here.
An HTTP web server is a computer program that receives requests from a browser in the form of URLs (web addresses) and responds with HTML pages, CSS styles, JavaScript files, images, etc. Node.js is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps and command line tools using JavaScript.
While there are many Node.js frameworks like Express.js, Meteor.js, Next.js etc. for creating web servers, we’ll use the built-in node:http
module in this tutorial to keep things simple. The server-side logic covered here can be easily replicated in any language or framework of your choice (e.g. Ruby on Rails, Python, Django, Go, Java, etc.)
To begin, let’s create a file index.js
, open it up in a code editor (like Visual Studio Code), and add the following code into it:
const http = require("node:http");
const fs = require("node:fs");
function renderHtmlPage() {
return `
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/styles.css" />
<script src="/script.js"></script>
<title>Account Settings</title>
</head>
<body>
<div id="container">
<h1>Account Settings</h1>
</div>
</body>
</html>
`;
}
function handleRequest(req, res) {
console.log(req.method, " ", req.url);
if (req.url === "/") {
// Respond with an HTML page
res.writeHead(200, { "Content-Type": "text/html" });
res.write(renderHtmlPage());
} else if (req.url === "/styles.css") {
// Respond with a CSS file
res.writeHead(200, { "Content-Type": "text/css" });
if (fs.existsSync("styles.css")) res.write(fs.readFileSync("styles.css"));
} else if (req.url === "/script.js") {
// Respond with a JavaScript file
res.writeHead(200, { "Content-Type": "text/javascript" });
if (fs.existsSync("script.js")) res.write(fs.readFileSync("script.js"));
} else {
// Reject all other URLs
res.writeHead(404);
res.write("Not Found");
}
res.end();
}
// Create server and listen on port 8080
http.createServer(handleRequest).listen(8080);
Here are some notes about the above code:
The above code, when executed, creates an HTTP web server that listens on the port 8080. Requests to the server are processed using the helper function handleRequest
.
The web server responds to the root URL /
with the HTTP 200 OK status code and a simple HTML page containing the heading "Account Settings" in its body.
The HTML source code for the page is created using the helper function renderHtmlPage
which uses the template literal syntax for multiline strings.
The HTML page references the URL /styles.css
for applying styles using CSS and the URL /script.js
for adding interactivity to the page using JavaScript.
The web server respond to the URLs /styles.css
and /script.js
with the contents of the files styles.css
and script.js
respectively (if these files exist). We’ll create these files in Part 2 of the series.
The web server responds all URLs other than /
, /styles.css
, and /script.js
with a HTTP 404 Not Found status code and a "Not Found" message.
You’ll need to install Node.js on your computer to run the server. Once installed, you can execute the following command on a Linux/macOS terminal or Windows command prompt to start the server:
npx nodemon index.js
The above command uses the nodemon package to automatically restart the server every time we make changes to the file index.js
. You can also run the server using the command node index.js
, but you’ll have to manually shut down and restart the server for every code change.
The web server is now running on your computer, listening for requests on the port 8080. You can open up the URL http://localhost:8080 in a browser to view the HTML page:
It’s a good practice to document form fields, their data types, and any constraints/validations they must satisfy before you start building a form. This can also help you decide whether the form should span multiple sections or pages.
Our "Account Settings" form will contain the following fields:
A real web application will probably use a database like MySQL to store account settings for different users. For this tutorial, however, we’ll store & update some dummy account settings data in a simple JSON file. Let’s create a file data.json
with the following contents:
{
"fullName": "John Doe",
"email": "[email protected]",
"avatarUrl": "https://i.pravatar.cc/250?img=8",
"bio": ""
}
Next, let’s define a function renderForm
within index.js
with the folllowing code:
function renderForm() {
// Read existing values from "data.json"
const initialValues = JSON.parse(fs.readFileSync("./data.json"));
return `
<form id="settings-form" method="post" enctype="multipart/form-data" action="/">
<fieldset>
${renderFullNameField(initialValues.fullName)}
${renderEmailField(initialValues.email)}
${renderAvatarField(initialValues.avatarUrl)}
${renderBioField(initialValues.bio)}
</fieldset>
<input type="submit" value="Save Settings" />
</form>
`;
}
Here are some notes about the above code:
renderForm
reads the contents of data.json
into the variable initialValues
. It's a JavaScript object with keys fullName
, email
, avatarUrl
& bio
pointing to their respective values.
renderForm
uses the form
element to create an HTML form. Here’s how it works:
The action
attribute specifies the URL that processes the form submission, which is /
in this case i.e. the same URL that displays the form.
The method
attribute specifies the HTTP method used for submission. It defaults to GET
, but POST
is a more appropriate choice when the form has large text fields or file upload fields.
The enctype
attribute must be set tomultipart/form-data
if the form contains file upload fields, as in this case.
The fieldset
tag inside a form helps improve the accessibility of the form for users dependent on screen readers or other assistive technologies.
The form contains a button for submitting a response, created using an input
tag with type
set to "submit"
. Its label is specified using the value
attribute.
renderForm
invokes helper functions (defined below) for creating specific form fields using the template literal string interpolation syntax . Values from initialValues
are passed into the respective helper functions (e.g. initialValues.email
is passed into renderEmailField
).
Let’s define a function renderFullNameField
within index.js
for the "Full Name" field:
function renderFullNameField(fullNameValue) {
return `
<label>
<div>Full Name</div>
<input
type="text"
name="fullName"
placeholder="Enter full name"
required
maxlength="64"
value="${escapeHtml(fullNameValue)}"
/>
</label>
`;
}
// Replace characters that disturb the HTML layout
function escapeHtml(unsafeValue) {
return unsafeValue
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
Here are some notes about the above code:
renderFullNameField
accepts a single argument fullNameValue
and returns a string representing the HTML code for the "Full Name" form field, pre-filled with the contents of fullNameValue
.
The outer label
tag is used to display the label "Full Name" for the form field. Placing the input
tag inside the the label helps screen readers and assistive technologies associate form fields with their labels.
The input
tag is used to create the actual form field where a user can enter some text. Its attributes are as follows:
type="text"
: Specifies that the field accepts text inputname="fullName"
: A unique field name used while processing submitted dataplaceholder="Enter full name"
: Hint text shown when the field is emptyrequired
: Indicates that the field is required and should not be emptymaxlength="64"
: Limits the length of field to 64 charactersvalue="${escapeHtml(fullNameValue)}"
: Sets the initial value of the fieldWhile setting the value
attribute, the helper function escapeHtml
to replace unsafe characters) like <
, >
, "
, etc. in fullNameValue
with special codes like <
, >
, "
, etc. to avoid disturbing the HTML layout.
Let’s define the function renderEmailField
within index.js
for the "Email" field:
function renderEmailField(emailValue) {
return `
<label>
<div>Email</div>
<input
type="email"
name="email"
readonly
value="${escapeHtml(emailValue)}"
/>
</label>
`;
}
Here are some notes about the above code:
renderEmailField
accepts a single argument emailValue
and returns a string representing HTML code for the "Email" form field, pre-filled with the contents of emailValue
.
The input
tag for the "Email" field has the following attributes:
type="email"
: Specifies that the value must be a valid email addressname="email"
: A unique field name used while processing submitted datareadonly
: Indicates that this field is read-only and cannot be edited by the uservalue="${escapeHtml(emailValue)}"
: Sets the initial value of the field, with unsafe characters encoded using escapeHtml
Let’s define the function renderAvatarField
within index.js
for the "Avatar Image" field:
function renderAvatarField(avatarUrl) {
return `
<label>
<div>Avatar Image</div>
<img src="${escapeHtml(avatarUrl)}" class="avatar-image" height="80">
<input
type="file"
name="avatarFile"
accept="image/jpeg, image/png"
/>
</label>
`;
}
Here are some notes about the above code:
renderAvatarField
accepts a single argument avatarUrl
and returns a string representing HTML code for the "Avatar Image" form field. However, unlike other input
types, a file upload field cannot be prefilled with an existing value.
The img
tag displays the existing avatar image. It has the following attributes:
src="${escapeHtml(avatarUrl)}"
: Sets the source URL to avatarUrl
, with unsafe characters encoded using escapeHtml
class="avatar-image"
: Applies a CSS class for styling the imageheight="80"
: Sets the height of the image to 80 pixelsThe input
tag creates a form field that allows the user to upload a new avatar image. It has the following attributes:
type="file"
: Specifies that the field is a file inputname="avatarFile"
: A unique field name used while processing submitted dataaccept="image/jpeg, image/png"
: Restricts file types to JPEG & PNG imagesLet’s define the function renderBioField
within index.js
for the "Bio" field:
function renderBioField(bioValue = "") {
return `
<label>
<div>Bio</div>
<textarea
name="bio"
placeholder="Add a bio"
maxlength="1000"
>${escapeHtml(bioValue)}</textarea>
</label>`;
}
Here are some notes about the above code:
renderBioField
accepts a single optional argument bioValue
(defaulting to an empty string) and returns a string representing HTML code for the "Bio" form field, pre-filled with the contents of bioValue
.
We use a textarea
tag instead of an input
tag because textarea
allows for multi-line text input, which is more suitable for a longer text field like "Bio".
The textarea
tag for the "Bio" field has the following attributes:
name="bio"
: A unique field name used while processing submitted dataplaceholder="Add a bio"
: Provides a hint to the user when the field is emptymaxlength="1000"
: Restricts the maximum number of characters to 1000The textarea
tag does not have a value
attribute. Instead, the initial content escapeHtml(bioValue)
is placed between the opening and closing textarea
tags.
The escapeHtml
function is used to encode any unsafe characters in the bioValue
that might interfere with the HTML layout.
Now that we have created the HTML layout for the form, let’s modify the renderHtmlPage
function to invoke renderForm
and display the pre-filled form below the heading:
function renderHtmlPage() {
return `
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/styles.css" />
<script src="/script.js"></script>
<title>Account Settings</title>
</head>
<body>
<div id="container">
<h1>Account Settings</h1>
${renderForm()}
</div>
</body>
</html>
`;
}
We can now save the file index.js
(which automatically restarts the server) and reload the browser to display the updated web page at the URL http://localhost:8080:
Here’s the functionality we have implemented so far:
data.json
.Note that our "Account Settings" form is currently unstyled and clicking on the "Save Settings" button simply reloads the page without saving the submitted data. We'll add this functionality in the subsequent tutorials:
In Part 2 of the series (coming soon), we’ll style the form using CSS rules and add interactivity & client-side validation using JavaScript.
In Part 3 of the series (coming soon), we’ll process form submissions on the server and redisplay the form with updated data and success/error messages.
The finished code for this tutorial (Part 1 of the series) can be found here. Stay tuned!