Conditional API Responses for JavaScript vs. HTML Forms
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 an 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 are 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 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 sent 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 an 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, 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 in 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.
- Check if the
Accept
header includes the string'application/json'
.
If so, we know the client is explicitly asking for a JSON response. - 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. - 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 as 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 computing like EdgeWorkers.
Edge computing 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 affect latency like edge computing 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 essential 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, please share it. It's one of the best ways to support me. You can follow me on Twitter if you want to know when new articles are published.