Conditional API Responses For JavaScript vs. HTML Forms

Learn how to build backend APIs that support progressive enhancement by detecting if a request was submitted with HTML forms or JavaScript.

Today, I’ll show you how to detect whether an HTTP request was submitted via HTML form or with JavaScript to the server.

The examples in this post are based on Nuxt.js and include references to a few global functions (defineEventHandler, getRequestHeaders, sendRedirect). It’s not important that you know how they work. Just focus on the concept. I’ll explicitly highlight the important bits

Here’s a very basic event handler for a Nuxt JS server. An “event handler” in Nuxt.js represents an HTTP endpoint.

We can write an event handler to create a API endpoint that generates and returns an object.

export default defineEventHandler(async (event) => {
  // Do some work; Get some data
  const data = { goodBoy: 'Nugget' };

  return data;
});

That object will be converted to a JSON string for the HTTP response.

What’s The Problem?

Again, the syntax and underlying framework is not important. The important thing to know is that requests made with JavaScript can handle JSON responses just fine, but it’s a different story for requests made with HTML forms. When a form is submitted the browser sends the request as a page navigation, causing the next page to render whatever the server responds with. If the response is JSON, it will render that JSON string to the page.

Not a great user experience.

Now you may ask yourself, if we’re building a JSON API, why should we care about HTML forms? And the answer is, even if you prefer using JavaScript to submit data, there are many scenarios in which JavaScript fails. So it’s a good idea to implement progressive enhancement by using JavaScript to enhance HTML forms in such a way that if JavaScript fails, the HTTP request falls back to a normal form submission. The user experience is not as sexy, but at least it’s not broken.

So how can we fix the JSON response issue?

The Ideal Option

If our request handler could detect whether a request was submitted with an HTML form, we could respond with an HTTP redirect telling the browser to load a specific URL. If the request was submitted with JavaScript, we could go ahead and return JSON.

There’s an HTTP header called Sec-Fetch-Mode, which browsers automatically include with every HTTP request (including HTML forms and JavaScript). We can use this header to distinguish between one or the other.

The header can have the following “directives” or values:

  • cors
  • navigate
  • no-cors
  • same-origin
  • websocket

We’re interested in navigate because it indicates an HTTP request was made via HTML page navigation. The navigate value is also send during HTML from submissions and it’s not allowed to be used in fetch requests.

With that in mind, we can modify our code to check if the request was sent via HTML form. If so, we can redirect the user. Here’s how it might look:

export default defineEventHandler(async (event) => {

  // Do some work; Get some data

  const data = { goodBoy: 'Nugget' };

  const headers = getRequestHeaders(event);
  const isHtml = headers['sec-fetch-mode'] === 'navigate';

  if (isHtml) {
    return sendRedirect(event, String(headers.referer), 303);
  }

  return data;
});

For whatever API you are building, you’ll probably start with some business logic that ultimately generates an object. The important part is what comes next. With the HTTP request headers, we can check if the Sec-Fetch-Mode header is set to navigate. If so, we know that this was submitted with HTML.

(IMPORTANT: Nuxt.js converts all headers to lowercase, but it’s technically valid to sent capitalized headers as well. Make sure to account for that in your application.)

If the request was submitted with HTML, it doesn’t make sense to return JSON. So instead, we can send a 303 redirect response back to the browser.

If you know where to redirect to, you’re all set. Many JSON APIs won’t know. In that case, it makes sense to send the request back to the page it came from using the referer header.

The Practical Option (Today)

Now, this is great, but there’s one problem. Safari doesn’t currently support Sec-Fetch-Mode (and neither do older browsers).

However, we can accomplish the same effect a different way until browser support is better. It’s not quite as convenient.

Since we can’t use Sec-Fetch-Mode to know for certain that a request came in with HTML, let’s swap the logic around and determine whether the request came in with JavaScript. In that case, we’ll respond with the JSON and if not, default to the redirect.

We have a few options.

  1. Check if the Accept header includes the string 'application/json'.
    If so, we know the client is explicitly asking for a JSON response.
  2. Check if the Content-Type header is set to 'application/json'.
    This is not the default value and it’s not a value that can be set with an HTML form, so it’s safe to assume that since the request was sent as JSON and the headers were customized, we can respond with JSON.
  3. Check for the existence of a custom/proprietary HTTP header like x-custom-fetch.
    HTML forms are very limited in what headers they can modify, and they cannot add custom headers like JavaScript can. If we find a custom header, we can assume the request was made with JavaScript.

We can modify our code to include these checks (you probably don’t need all of them, but I’ll include them anyway).

export default defineEventHandler(async (event) => {
  // Do some work; Get some data
  const data = { goodBoy: 'Nugget' };

  const headers = getRequestHeaders(event);

  const isJson =
    headers.accept?.includes('application/json') ||
    headers['content-type']?.includes('application/json') ||
    headers['x-custom-fetch'];

  if (isJson) {
    return data;
  }

  return sendRedirect(event, String(headers.referer), 303);
});

Again, if any of those conditions are met, we know the request must have been created in JavaScript. If that’s true, we respond with JSON. Otherwise, we redirect.

Edge Compute Bonus

Another little tip I want to share is that this feature we’ve built is actually a perfect candidate for edge compute like EdgeWorkers.

Edge compute allows you to add custom logic between the client and the server and intercept or modify requests and/or responses. This means you could get the response from a JSON API and patch this functionality on top. Check if the request was sent with HTML or JavaScript; for HTML, redirect to the referer; for JS pass the response through.

This wouldn’t effect latency like edge compute is known for, but it’s a handy way to add features to an API without modifying the existing API code base. Maybe you don’t work for the company or team responsible for the API.

Caveats

Now, it’s important for me to point out that all of these for detecting JavaScript requests require checking for headers that are not included by default with a standard HTTP request. So you’ll want to make sure you communicate to developers that they’ll have to include the required headers when constructing HTTP requests in order to receive a JSON response.

At least, that should only be the case until browser support gets better and we can rely exclusively on Sec-Fetch-Mode.

If you want to see an example of this in practice, I made a video that covers this same topic.

What’s The Fuss Again?

Now, you may be thinking to yourself, well, that’s kind of cool, but why should I care?

This comes back to the concept of progressive enhancement, which allows developers to build applications that are enhanced to use JavaScript when they can, but if something happens and JavaScript fails or it’s blocked or whatever, it can fall back to using the form to submit a request.

However, there is only so much that client-side developers can do. If an API always responds with JSON, then progressive enhancement will make sure the request still works, but the response will be broken.

By building APIs that enable progressive enhancement, we enable developers to build progressively enhanced apps.

Thank you so much for reading. If you liked this article, and want to support me, the best ways to do so are to share it, sign up for my newsletter, and follow me on Twitter.


Originally published on austingil.com.

2 Comments

  1. Nice post! One thing that may trip folks up is sometimes HTTP headers come in capitalized. You’ll want to check for ‘accept’ or ‘Accept’ and ‘content-type’ or ‘Content-Type’. We’ve run into this one in the wild a bunch. It really depends on what the client sends as headers.

    • Yeah, you’re right. The framework I’m using lowercases all headers, but this is a good point to call out in the article. I’ll add a note.

Leave a Reply

Your email address will not be published. Required fields are marked *