Move a user's country to the top of a select element with Netlify Edge Functions and geolocation
Tags: netlify, html, javascript, tutorial
Published: 2022-12-14 18:46:46

Imagine we have some generated HTML, it could be some hand-written static HTML, it could be the output of a CMS like Drupal or WordPress, it could be created by a tool like Astro or Eleventy, or really by any framework that sends proper HTML responses (not just `<div id="root"></div>` to a browser. Maybe the HTML is a checkout form, a contact form, or any other form that collects a user's country via a `select` element or 'dropdown list'.

Wouldn't it be nice if the user's country was always at the top of the list so they could find it easily without any scrolling or typing?

We could [get the user's latitude and longitude with JavaScript](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API/Using_the_Geolocation_API), but the user would have to give permission. Asking for permission for geolocation without explaining why you need it or without tying it to a user's interaction (like the user clicking on a '_Closest to my location_' button in a store finder or mapping app) is a no-no. Plus, we'd still have to use an API to turn that latitude and longitude into something that represents a country.

If your site had a backend, then you could write some custom code to turn the visitor's IP into a country code, or maybe find a plugin to do it, but that would likely involve paying for a geolocation API subscription, buying, writing, or at least installing a plugin, and using up valuable development resources. Plus, if your HTML is hand-written, coming from a third-party, or coming from a system you don't want to take on maintenance responsibility for, then the chances are that you won't be able to add this functionality at the application level at all. You might also run into some issues with your custom code not actually picking up the user's IP address, but the IP address of your CDN, load balancer, or firewall, or even worse, a different user's IP address if the HTML response was cached.

Instead, we can use an Edge Function to benefit from Netlify's [free geolocation functionality](https://edge-functions-examples.netlify.app/example/geolocation) and to _transform_ the HTML to do what we want. That way it doesn't matter where the HTML came from or what technology stack produced it, and because it works on 'the edge' (a point in the network as close as possible to the user) you're less likely to run into issues with reverse proxy caches, load balancers, web application firewalls and so on, because the response being transformed by the edge function will have already passed through many of these layers.

# Pre-filling the user's country 

My first approach as I played around with the idea was to automatically select the user's country on the list. Before we talk about the issues with this approach, here's how I did it:

```javascript
import { HTMLRewriter } from "https://ghuc.cc/worker-tools/html-rewriter@v0.1.0-pre.17/index.ts";

export default async (request, context) => {
  // Get the country code from the incoming request using the
  // `context` object provided by Netlify. This information
  // is gathered by comparing the user's IP address against
  // MaxMind's GeoIP2 database.
  const countryCode = context.geo?.country?.code;
  // Get the response provided by Netlify - this contains our
  // original HTML that we want to modify.
  const response = await context.next();

  if (!countryCode) {
    // If we don't have a country code, return the response 
    // as-is.
    return response;
  }

  // If we have a country code, use that to pre-select an 
  // option in the form by adding the `selected` attribute.
  return (
    // Use the html-rewriter tool to modify the original
    // response.
    new HTMLRewriter()
      // We use an attribute selector to find the `option`
      // element with a value that matches our user's country
      // code. Change `#country-1` to a selector that matches 
      // your target `select` element.
      .on(`#country-1 option[value="${countryCode}"]`, {
        element(element) {
          element.setAttribute("selected", "");
        },
      })
      .transform(response)
  );
};
```

You can see an example here: [https://edge-country-code-select.netlify.app](https://edge-country-code-select.netlify.app).

The Edge Function will find the `option` that represents the user's country (based on their IP address) and mark it as selected by modifying the HTML before it gets sent to the browser. If you lived in the UK, the HTML would look something like this:

```html
<select required id="country-1" name="country-1">
  <!-- all the A-U countries… -->
  <option value="GB" selected>
    United Kingdom of Great Britain and Northern Ireland
  </option>
  <!-- all the U-Z countries… -->
</select>
```

This _feels_ smart at first but could annoy people browsing when travelling, or connected to a VPN based outside of the country that they live in. It could result in incorrect data if these people submit the form without checking the prefilled value. 

A safer approach is to avoid prefilling the form field but to still make it easy for a user to pick their country from the list.

## Bringing the user's country to the top of the list

By moving the geolocated country to the top of the list we are suggesting it, but not choosing on behalf of the user.

Here's how the end-result HTML could look:

```html
<select required id="country-2" name="country-2" data-country="GB">
  <option disabled selected value="">Select a country</option>
  <option value="GB">
    United Kingdom of Great Britain and Northern Ireland
  </option>
  <option value="" disabled>--------</option>
  <option value="AF">Afghanistan</option>
  <!-- All the A-Z countries… -->
  <option value="ZW">Zimbabwe</option>
</select>
```

And the result in a browser:

![Screenshot of a rendered HTML select element with an option representing the user's country at the top of the list, then a divider option, then options for each country name](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wtepgl46t8jedx89gldp.png)

You can see the example here again: [https://edge-country-code-select.netlify.app](https://edge-country-code-select.netlify.app).

Here's the code:

```javascript
import { HTMLRewriter } from "https://ghuc.cc/worker-tools/html-rewriter@v0.1.0-pre.17/index.ts";

export default async (request, context) => {
  // Get the country code from the incoming request using the
  // `context` object provided by Netlify.
  const countryCode = context.geo?.country?.code;
  // Get the response provided by Netlify - this contains our
  // HTML.
  const response = await context.next();

  if (!countryCode) {
    // If we don't have a country code, return the response
    // as-is.
    return response;
  }

  // Return the response once it's passed through the HTML Rewriter.
  return (
    new HTMLRewriter()
      .on(`#country-2`, {
        element(element) {
          // Set a data attribute containing the country code onto the select
          // element, we'll use this next.
          element.setAttribute("data-country", countryCode);
          // Add some inline JavaScript to the page that will read the country
          // code data attribute and move the right option to the top of the list.
          const id = element.getAttribute("id");
          element.after(
            `<script>
              (() => {
                // Get the select element using whatever ID the HTML Rewriter used.
                const select = document.getElementById("${id}");
                // Get the country from the data attribute.
                const country = select.dataset.country;
                // Find the option that matches the user's country.
                const option = select.querySelector(\`option[value="\${country}"]\`);
                // Create a disabled divider option.
                const dividerOption = document.createElement("option");
                dividerOption.setAttribute("value", "");
                dividerOption.setAttribute("disabled", "");
                dividerOption.innerText = "--------";
                // Insert the divider option before the second option
                select.insertBefore(dividerOption, select.querySelector(":nth-child(3)"));
                // Insert the country option before the divider option
                select.insertBefore(option, select.querySelector(":nth-child(3)"));
              })();
            </script>`,
            { html: true }
          );
        },
      })
      .transform(response)
  );
};
```

This is a slightly different approach, we're letting client-side JavaScript do most of the work rather than doing it in our Edge Function. I originally wanted to do all the transforming of the HTML in the Edge Function, but I found it difficult because of how the HTML rewriter tool works. It processes the response an element at a time, top down (so that it can support streaming), so it's difficult to go back in time to change what has come before. This matters as we need to find the `option` element that represents our user's country, then move it to the top of the list - but at that point the earlier `option`s at the top of the list have already been processed so we have missed our chance.

An alternative option would be to do some string replacement, but this would involve [writing regexes for HTML (no thank you!)](https://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags/1732454#1732454) 🤢.

Another option would be to forget about 'moving' the existing DOM node to the top of the list, and instead use the Edge Function to create a brand new DOM node using the data from `context.geo.country.name` for the option visible text and `context.geo.country.code` for the option value. This would work, but we could cause problems as our Edge Function wouldn't be aware of any extra HTML attributes that the application might set on the `options`. We might also run into issues with country names; the country name provided by Netlify would be in English, but what if the rest of the site used country names in French or Spanish?

All these reasons made me think it'd be fine to add some client-side JavaScript for this. It is a progressive enhancement, after all. If the JS fails then the menu will work exactly as originally intended.