Coding ยท ยท

Submitting HTML <form>s

You'll learn how to submit data from a <form> to a server, without requiring client-side JavaScript. Then you'll learn about client-side and server-side validation, and why server-side validation is always needed.

Reminder: client-side vs server-side

While browsers generally only understand JavaScript, servers can be in pretty much any language - traditionally, PHP was very popular, though these days JavaScript can also be used on the server, eg via Bun.

Servers receive requests from clients and send back responses, which might be HTML, an image, a CSS file, a video, some JSON, or anything else.

Forms and GET vs POST requests

When you visit a web page, your browser sends a GET request for a certain path on that server. So if you visit https://example.com/blah.html the server running example.com receives a GET request for /blah.html and responds with the relevant HTML page, which you see rendered in the browser. Your browser may then make more GET requests for resources mentioned in the HTML, such as CSS and JS files, images, videos etc.

Sometimes instead of just accessing/getting something, we want the server to do or change something, like send an email or add data to a database. We should send a POST request instead of a GET request to do this. In a previous lesson, you used JavaScript in the browser to make GET and POST requests using fetch() like this:

// a GET request
let data = await (await fetch('https://api.compsci.me/hello')).text()
console.log(data)

// a POST request
await fetch('https://api.compsci.me/things', {
  method: 'POST',
  body: prompt('What thing would you like to add?')
})

Because it's just JavaScript code, you can run it any time, such as when someone clicks a button, in a setInterval(), when a page loads, when a user's pointer moves... anywhere.

But fetch() is a relatively recent (since ~2015) addition. There's another way to do GET and POST requests, using <form>s, and it doesn't require any JavaScript at all. Back in the day, this is how all POST requests were made, and it's still very much relevant today too. In addition, questions on POST'ing <form>s will come up in exams.

A basic GET form, client-side

NOTE: don't replace your main website with this - probably best to do it outside your website repo, in a new folder.

Make an index.html file with the following:

<!doctype html>
<title>Forms</title>
<h1>Forms</h1>
<form>
  <label>Number 1: <input type="text" name="num1"></label>
  <button>Submit</button>
</form>

Run the file with bun index.html and access it in a web browser. Put a number in and either press 'Enter' on your keyboard or press the 'Submit' button.

You'll notice that the input box is now empty, but if you look at the URL it has changed from http://localhost:3000/ to eg http://localhost:3000/?num1=10.

Add a place for the user to add a second number <label>Number 2: <input type="text" name="num2"></label> below the first one (above the <button>), fill them both in and submit the form again. This time you're whisked away to eg http://localhost:3000/?num1=5&num2=10

When you put things like <input> inside a <form> element, submitting the form sends a GET request to the server with key=value pairs for each of your form fields (<input name="num1"> will have key num1), which currently responds with the same HTML page (but could handle the request differently).

We can access these key-value pairs in the browser with JavaScript, via new URL(location).searchParams.get('num1'). Let's use that to make a simple addition calculator:

<!doctype html>
<title>Forms</title>
<h1>Forms</h1>
<form>
  <label>Number 1: <input type="text" name="num1"></label>
  <label>Number 2: <input type="text" name="num2"></label>
  <button>Submit</button>
</form>
<script>
let num1 = Number(new URL(location).searchParams.get('num1'))
let num2 = Number(new URL(location).searchParams.get('num2'))
if (num1 && num2) {
  alert(`Sum of ${num1} and ${num2} is ${num1 + num2}`)
}
</script>

Load http://localhost:3000/ then enter 2 numbers and submit the form. You should see an alert() giving you the sum. You don't even need to submit the form - just manually change the numbers in the URL and hit enter to request a new page.

Server-side form handling

Change the opening <form> tag to <form action="/add">. Then submit the form again and you'll notice you're taken to http://localhost:3000/add?num1=5&num2=10. We can use action="some-page" to submit the form to a different URL (inc to a completely different website), and we're about to handle that submission server-side.

First, remove the <script> in your HTML so you're left with just this:

<!doctype html>
<title>Forms</title>
<h1>Forms</h1>
<form action="/add">
  <label>Number 1: <input type="text" name="num1"></label>
  <label>Number 2: <input type="text" name="num2"></label>
  <button>Submit</button>
</form>

Then add a server.js:

import homePage from './index.html'

let server = Bun.serve({
  routes: {
    '/': homePage,
    '/add': (req) => {
      let searchParams = new URL(req.url).searchParams
      let num1 = Number(searchParams.get('num1'))
      let num2 = Number(searchParams.get('num2'))
      let res = `${num1} + ${num2} = ${num1 + num2}`
      console.log(res)
      return new Response(res)
    },
  }
})

console.log(`Server at ${server.url}`)

This time, instead of bun index.html you need to run bun --hot server.js. Then go to http://localhost:3000/, input and submit 2 numbers, and you should be whisked away to a server-generated page showing eg 2 + 3 = 5 (you should find the same message in the terminal Bun's running in).

Unfortunately let num1 = (new URL(req.url).searchParams).get('num1') is a bit ugly (hopefully Bun will fix that soon), but that's what we need to use server-side to extract the key-value pairs out of the URL. As before, you don't need to submit the form - you can manually change the URL in your browser too.

Notice that now the processing (ie the JavaScript) is purely on the server - there's no <script> element with any JavaScript running in the browser at all.

While here we're just adding 2 numbers, you could do all kinds of server-side processing with the data received from the form, such as:

Server redirects

Change your index.html to:

<!doctype html>
<title>Form</title>
<form action="/go">
  <label>Page: <input type="text" name="page" autofocus></label>
  <input type="submit" value="Go">
</form>

... and open http://localhost:3000/ in a browser. Notice the autofocus in the HTML? It's a really handy usability feature - can you see what it did? I've used <input type="submit" value="Go"> - this works the same as <button>Go</button>, but is how you'll see it done in exam questions, so you may as well get used to it now.

Enter something in the text box and submit the form. You'll be taken to /go?page=whatever, which will currently 404 but we'll set up a server handler for that now.

In your server-side code, add this to your routes object:

'/go': (req) => {
  return new Response(`You are looking for page "${new URL(req.url).searchParams.get('page')}"`)
},

... refresh the page at eg http://localhost:3000/go?page=cats and you'll see the message as expected.

Instead of just showing a message, we're going to:

Firstly, we'll need to initialize the array to keep track of the pages:

let goPages = []

... which you should add above let server = Bun.serve({ to make it a global variable.

Next, we need to change your existing /go route to:

'/go': (req) => {
  let val = new URL(req.url).searchParams.get('page')
  goPages.push(val)
  console.log('/go', val)
  return Response.redirect(`https://en.wikipedia.org/w/index.php?search=${val}`)
},

And add a new route for /visits:

'/visits': () => Response.json(goPages),

Go back to http://localhost:3000/ and put something like "badgers" in the form, and it should take you to the relevant Wikipedia page. And if you visit http://localhost:3000/visits it will return some JSON with an array holding the history of all requests since the server last restarted.

The way the redirect works is that when you return Response.redirect(url) the server sends a special redirect response, and when the browser receives that response it does a GET request for the new URL, which the server then responds to. All of this is largely hidden to the user.

In case something isn't working, compare your current server.js with this one that you should have:

import homePage from './index.html'

let goPages = []

let server = Bun.serve({
  routes: {
    '/': homePage,
    '/go': (req) => {
      let val = new URL(req.url).searchParams.get('page')
      goPages.push(val)
      console.log('/go', val)
      return Response.redirect(`https://en.wikipedia.org/w/index.php?search=${val}`);
    },
    '/visits': () => Response.json(goPages),
  }
})

console.log(`Server at ${server.url}`)

7. Processing POST form submissions

Our previous form used <form action="/go"> which is equivalent to <form method="get" action="/go">. GET is the default method, which should be used when you're getting but not editing anything. If you have a form which results in something being changed (such as an email being sent or a database record being edited) then you should do a POST request instead.

Add a 2nd form to your index.html so it now has the following:

<!doctype html>
<title>Form</title>

<form action="/go">
  <label>Page: <input type="text" name="page"></label>
  <input type="submit" value="Go">
</form>

<form method="post" action="/edit">
  <label>New value: <input type="text" name="thingy" autofocus></label>
  <input type="submit" value="Save">
</form>

This 2nd form will submit to /edit, so we need a route handler, but this time it needs to handle a POST route as we're using method="post":

'/edit': {
  POST: async (req) => {
    let data = await req.formData()
    return new Response(`"${data.get('thingy')}" POSTed to /edit`)
  }
},

Notice that it's a bit more tricky to get the data from a POST request. Firstly, we've specifically made the request handler relevant only to POST requests by wrapping it in an object with POST as the key. Also, we need to await req.formData(), which means the request handler has to be an async function. Then data isn't a normal object, but an object that encapsulates private attributes and requires us to use a getter to access them, with data.get('thingy').

Submit the 2nd form and ensure you can see the "blah" POSTed to /edit message. One thing you'll notice is that the GET form added the submitted key-value pairs to the URL itself, while for POST forms they're not (you're just taken to /edit not /edit?thingy=whatever). Instead, they're sent via the body of the HTTP request. You can see these if you open the 'Network' tab in developer tools, refresh the page and click on the request to expand it: under 'Request headers' you'll see Content-Type: application/x-www-form-urlencoded and under the 'Payload' sub-tab you'll see the key-value pair for 'thingy'.

As per with GET form handlers, we could do anything with the data we're given, such as update or insert a row into a database. Here we're going to:

Initialise a couple of variables at the top of your server.js file:

let thingy = '_empty_'
let count = 0

Then replace your code for the /edit route with this:

'/edit': {
  POST: async (req) => {
    let data = await req.formData()
    thingy = data.get('thingy')
    count++
    return Response.redirect('/thingy')
  },
},

And add another route for /thingy:

'/thingy': () => new Response(`"thingy" has been changed ${count}x and is now "${thingy}"`),

Go to http://localhost:3000/ and submit the form. You'll be redirected to /thingy, shown how many times the variable has been changed, and its current value.

It's generally a good idea to redirect after processing a POST request. This stops data getting resubmitted if the user reloads the page, which would cause the server to do the processing again, potentially eg duplicating data in a database or sending duplicate emails. This is called the PRG pattern, standing for "POST, Redirect, GET".

Client-side vs server-side validation

Classic exam question material here, asking you to compare client- vs server-side validation, and getting you to do some form validation. OCR only really use JavaScript as something that runs in a browser to process forms before they get submitted to the server. You can use JavaScript in this way to validate that data is how you need it, that users have filled in all the required fields etc.

You must never trust data from a user - there's nothing to stop a user from disabling JavaScript in their browser or skipping the browser entirely and sending a request using eg fetch() from a Bun script instead.

It is essential to always validate server-side for security.

So why bother with also having client-side processing of form data?

So you have a choice: either do server-side only validation, or do both client- and server-side validation. You must not choose client-side only for security reasons.

We're going to work through a user login form, keeping security in mind. Here's the HTML we're going to add to our page for it:

<form id="loginForm" method="post" action="/login">
  <fieldset>
    <legend>Log in</legend>
    <label>Email: <input id="email" type="text" name="email"></label>
    <label>Password: <input id="password" type="password" name="password"></label>
    <input type="submit" value="Log in">
  </fieldset>
</form>

The <fieldset> and <legend> make it look a bit nicer. We've given elements id="..." values to make them easier to select. The type="password" obscures text entered into that field, stopping someone lurking over their shoulder from seeing a password. We're POST'ing the form rather than GET'ing because logging in probably changes something on the server, and if we used GET the password would end up in the URL, which is very bad for security.

Before doing any server-side processing, we'll start by adding some client-side processing. Let's make sure they've entered an email address and password:

<script>
document.getElementById('loginForm').onsubmit = (e) => {
  let email = document.getElementById('email').value
  let password = document.getElementById('password').value
  if (!email || !password) {
    e.preventDefault()
    alert('I cannot log you in without email and password!')
  }
}
</script>

... we add an onsubmit handler function for the form rather than an onclick handler for the button - this is the correct way to deal with forms, and ensures a user can press 'Enter' when in the form or click the button to submit it.

We've temporarily added e.preventDefault() to stop the form doing its default action, which is to send the data to the server. We'll remove that once things are working.

If you open your console and submit the form, you should see an alert if you don't provide both email and password. If you do provide both, then you'll be taken to /login (which is currently a 404).

You can see how trivial it is for a user to bypass client-side validation by going back to the form, leaving the fields empty and running document.getElementById('loginForm').onsubmit = undefined in the console then resubmitting the form. Remember: you always need server-side validation, while client-side validation is optional.

We're going to validate 2 more things: password is at least 8 characters long; email has an @ in it. Replace the previous code with this:

<!-- add this somewhere on your page, eg under the 'Log in' button -->
<p id="error" style="color: red; background: yellow;"></p>

<script>
document.getElementById('loginForm').onsubmit = (e) => {
  let email = document.getElementById('email').value
  let password = document.getElementById('password').value
  let problem = loginFormProblem(email, password)
  if (problem) {
    e.preventDefault()
    document.getElementById('error').innerText = `Cannot submit form: ${problem}`
  }
}

function loginFormProblem (email, password) {
  if (!email) return 'you need an email'
  if (!password) return 'you need a password'
  if (!/@/.test(email)) return 'not a valid email'
  if (password.length < 8) return '8+ characters for password'
  return false // no validation problems
}
</script>

... refresh the login page and you'll see we're no longer using a horrible alert() but showing the validation message to the user on the page instead.

Note that I split a pure function loginFormProblem() out of the onsubmit handler. The fact it's pure makes it much easier to test, and in fact we could (and we will shortly) use this same function on the server.

I'm sure you can think of more/better ways to validate this form, but we'll leave it there for this guide.

Now we'll wire up a route on the server to receive this form submission. Here's a full server.js to handle it:

import homePage from './index.html'

let server = Bun.serve({
  routes: {
    '/': homePage,
    '/login': {
      POST: async (req) => {
        let data = await req.formData()
        let email = data.get('email')
        let password = data.get('password')
        let problem = loginFormProblem(email, password)
        if (problem) return new Response('Bad!')
        else {
          // ... check if user with email/password in database
          // ... add session cookies to log user in
          // ... redirect to a user dashboard page
          return new Response('Good!')
        }
      },
    },
  }
})

console.log(`Server at ${server.url}`)

function loginFormProblem (email, password) {
  if (!email) return 'you need an email'
  if (!password) return 'you need a password'
  if (!/@/.test(email)) return 'not a valid email'
  if (password.length < 8) return '8+ characters for password'
  return false // no validation problems
}

... notice the exact same loginFormProblem() function at the bottom that we used on the client-side? It's beyond the scope of this guide, but as we're using the same function on client and server we could put it in a separate .js file and import from both our client script and our server. Then we wouldn't accidentally add a new validator in one but forget to add to the other.

At the moment our server code just shows a page with 'Good!' or 'Bad!' on it, but you can see where you would hook in code to actually log the user in.

Add to your website

There's a lot more you can do with forms, and you're encouraged to and add some form functionality (both GET and POST) to your website.

Explore further

Here are some other things to explore. But remember: always validate server-side.