React Router on a Nested S3 Bucket

Brian Whitton
4 min readOct 17, 2017

--

Are you trying to deploy a React app to S3 that uses react-router for client-side routing? If your app lives on a bucket with path prefix (i.e. http://<your bucket>/<some path>/), then you’re gonna have some problems.

When the WNYC Newsroom launched our 2017 NYC General Election Voter’s Guide, we wanted shareable URLs, but it wasn’t as straightforward as I would have hoped. What follows is a deep dive into S3 bucket configuration I recently took that solved the problem for us.

To start, I chose react-router because a previous iteration of the NYC Voter Guide had been built using React, and it’s really the only choice when it comes to client-side routing with React.

Client-side Routing and S3

The typical approach to serving JS apps with URL handling from S3 is to configure the bucket so the app’s index file is specified as the bucket’s error document. This way, even if a pathname doesn’t point to an actual file on S3, the index file will load up, boot the app, and run its own routing logic based on the current URL.

The problem is that buckets allow for only one error handler, and all of our web apps are served from the same bucket. Each one has a different path prefix that loads the app’s index file. This election guide lives under nyc-general-2017.

react-router provides a HashRouter, which uses location.hash to do routing. This would work, since anything after the # in a URL isn’t sent in the request, so these nested paths wouldn’t return a 404.

Unfortunately, an important note in the react-router docs practically says don’t bother using it:

…this technique is only intended to support legacy browsers, we encourage you to configure your server to work with <BrowserHistory> instead.

So what to do? We can’t use the HashRouter, we can’t use the bucket’s error document for just this app, and the idea of creating a special “error app” that would somehow know how to load an intended app given a pathname prefix just sounds insane.

S3 Redirects

S3 buckets have a feature known as Redirection Rules. Using a small set of XML tags, one can define specific redirects to take place under a set of given conditions.

With these rules, I specified that if a request comes in for a file under the nyc-general-2017 prefix AND that file doesn’t exist, then redirect the request, replacing the nyc-general-2017 part of the url for nyc-general-2017/#.

The subsequent request fetches the app’s index file, but the trailing path remains as part of location.hash.

Here’s what the XML looks like for this rule:

<RoutingRule>
<Condition>
<KeyPrefixEquals>nyc-general-2017/</KeyPrefixEquals>
<HttpErrorCodeReturnedEquals>403</HttpErrorCodeReturnedEquals>
</Condition>
<Redirect>
<Protocol>https</Protocol>
<HostName>project.wnyc.org</HostName>
<ReplaceKeyPrefixWith>nyc-general-2017/#</ReplaceKeyPrefixWith>
</Redirect>
</RoutingRule>

So if there’s a request for https://project.wnyc.org/nyc-general-2017/districts/40, S3 will see that there’s no object named districts/40 (remember that it’s an ephemeral route), and then issue a 301 response, redirecting to https://project.wnyc.org/nyc-general-2017/#districts/40, which just returns the index file of the app.

The app uses react-router's BrowserRouter, which manages the URL using the history API. So when the app boots, I inspect the value of the hash and use the history API to replace the state. Here’s what that looks like:

// index.js...let path = location.hash.slice(1);
if (path) {
location.hash = '';
history.replaceState({}, '', `${ROOT_URL}${path}`);
}
ReactDOM.render(<App />, document.getElementById('root'));

In this instance, ROOT_URL is defined as /nyc-general-2017/.

So now a request for https://project.wnyc.org/nyc-general/districts/40 will get a response that looks like this:

HTTP/1.1 301 Moved Permanently
Content-Length: 0
Connection: keep-alive
Date: Tue, 17 Oct 2017 22:22:58 GMT
Location: https://project.wnyc.org/nyc-general-2017/#districts/40
Server: AmazonS3

Which then returns the app located at https://project.wnyc.org/nyc-general-2017/. When the app boots in the browser, it reads in the value of location.hash, which is #districts/40, and uses the history API to update the URL with a “real” value, setting it to https://project.wnyc.org/nyc-general/districts/40, the page you were looking for originally.

And because the app, via react-router, is configured to read in the value of the URL and navigate to the intended route on the client side, everything works as expected.

Caveats

Users will see a quick flash in the browser bar of as the URL is redirected from /bucket-path/nested/path to /bucket-path/#nested/path and back again, but this feels like a minor concession.

The other, and potentially more problematic caveat has to do with network caches. If you are using a Cloudfront distribution to direct traffic to your S3 bucket, then you might find yourself in a tough spot depending on how you’ve configured Cloudfront to cache redirect responses.

I’m not going to go into the details of how to mitigate that, but at a minimum you could maintain a short TTL on your Cloudfront distro when it comes to redirects, otherwise you might find that a redirect response is cached for an asset you upload to S3 after Cloudfront caches the response.

S3 Redirects

They come in handy! Hopefully this can help you out if you’ve found yourself in a similar situation. Let me know if you’ve got a question or just feel like randomly trolling. See you on the internet!

--

--