Navigation state in webapps
Modern web applications handle their navigation state internally and don’t require page reloads when the user is moving between the pages. This is a convenient feature as that also means ephemeral state, such as text entered into input fields and popups are preserved. But that also means, by default, that the URL that the users see stays the same and if they choose to reload the page they are taken back to the home page.
One solution for both of these problems was the use of fragments. This is the part that comes after the
# and the webapp is free to change it and that does not cause a page reload.
// url is: example.com window.location.hash = "articles"; // url is: example.com/#articles
And if the user reloads the page, the webapp can restore the navigation by reading the fragment:
// url is: example.com/#articles window.location.hash; // #articles
This was the de-facto solution before HTML5 History API. The problem with fragments is that they are different. Instead of having a “normal-looking” URL, such as
example.com/articles, it is
HTML5 History API
The History API provides a solution for all the problems with fragments and it is widely supported in browsers. It provides a
pushState that changes the URL in a way that does not trigger a page reload.
// url is: example.com history.pushState(undefined, undefined, "/articles") // url is: example.com/articles
But it also introduced a new problem: what happens if the user reloads the page?
With fragments, the HTTP request goes to the same URL for all pages. But with
pushState, it’s no longer the case. Worse still, if the backend can not serve a HTML page for the changed URLs it’s not immediately apparent.
Here’s a video showing the problem:
This article focuses on how to configure CloudFront for this scenario but the solution described here works for other backends too.
Let’s consider a typical setup: the frontend is hosted behind a CloudFront distribution and that uses the HTML5 History API to provide friendly URLs. By default, refreshing the page returns an error.
The first request works as when CloudFront forwards it to the origin the URL points to a file that exists on the backend. Navigation works as that does not trigger a new request. But refresh is broken as the new URL does not point to a file on the backend.
One solution is to configure the backend to return the
index.html for URLs that has no matching file. If you use Apache, Nginx, or another custom webserver then you can check the documentation how to do it. But there are cases, such as for S3 that does not support this behavior. In that case, you need to configure CloudFront to handle it internally.
CloudFront supports the notion of “error response”. If the origin returns a specific type of error, CloudFront can automatically modify the request and return a different response.
To support HTML5 History API, configure a custom response for the 404 errors:
How this works?
CloudFront still sends a request to the backend and that responds with a 404 code. This triggers the custom error response and CloudFront sends another request to the
/ path. The backend responds with the
index.html and CloudFront returns that with the 200 status code.
Alternative: CloudFront Functions
An alternative solution is to use CloudFront Functions to dynamically rewrite the request path. This way you have more control over which paths are changed and which should generate 404 errors instead.
history.pushState method allows a web application to change the page URL without triggering a HTTP request. This allows nicer-looking navigation and permalinks but if the user reloads the page the request goes to the path that is shown in the URL bar. If the backend does not support it, this results a 404 error.
CloudFront offers a custom error response setting to handle these errors. Alternatively, you can use CloudFront Functions to rewrite the URL before the request is sent to the origin.