DEV Community

Cover image for Handling client-side routing when hosting a react app on AWS S3
James Rodgers
James Rodgers

Posted on

Handling client-side routing when hosting a react app on AWS S3

As a recent Bootcamp grad I wanted to put my new React skills to work and tie in my older skills with AWS. I built out my new portfolio site, using React, and I thought to myself, “can I host this in an S3 bucket?” The first task is deploying to S3, which was pretty straightforward and there are many good resources that you can find with a little googling, like this one, stepping through how to do that. I was replacing an existing site with my new improved react site, so I already had the bucket set up and Route 53 configured etc. I pushed up my project and pulled it up in my browser and everything seemed to be working great. Success!
But there was one big problem, if you hit the refresh button on the browser, you got a 404 error. Why was this? I had noticed this on a school project deployed to Heroku earlier but hadn’t had time to investigate then, now I wanted to get to the bottom of it. With some googling I found some explanations and some approaches to a fix but not the entire answer. There is a great explanation of the problem here on stack overflow, I’ll try a quick explanation myself.
Basically, since React-router is “client-sided” navigation when you send a refresh command, the browser is stepping in and it looks for the index file at the end of the address path but there isn’t one. So, in my portfolio example, if you’re looking at the ‘/About’ page, in a traditional static website, there would be an index file in the ‘/About’ folder that the browser would go read and display on the page. But in React, ‘About’ is a component that React is instantiating on the page but there is no resource at the end of that path for the browser to look at. The browser is really always looking at the index file at the root of the site and react-router is reading the address bar and switching between components.
After more googling around I got a big chunk of a solution in this post by Mike Biek. The big insight here is that you can do some “server side” routing in AWS S3 using Redirection rules and conditional redirects. Conditional redirects allow you to react to error conditions so, when refresh is clicked and the page fails to load it would return a 404 error. Using the Redirection rules we can prepend a ‘#!’ in front of the address, the browser won’t read anything after the ‘#’ ’ and will therefore go to your home '/' route page but we’ll still have the address available in the address bar to work with.
Here is the file, copied right out of Mike Biek’s post…

<RoutingRules>
    <RoutingRule>
        <Condition>
            <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
        </Condition>
        <Redirect>
            <HostName>myhostname.com</HostName>
            <ReplaceKeyPrefixWith>#!/</ReplaceKeyPrefixWith>
        </Redirect>
    </RoutingRule>
    <RoutingRule>
        <Condition>
            <HttpErrorCodeReturnedEquals>403</HttpErrorCodeReturnedEquals>
        </Condition>
        <Redirect>
            <HostName>myhostname.com</HostName>
            <ReplaceKeyPrefixWith>#!/</ReplaceKeyPrefixWith>
        </Redirect>
    </RoutingRule>
</RoutingRules>

To find where these rules go, open the S3 service on your AWS account. Click on the bucket you are using to host your project, then click on properties, then click on the square that says “Static website hosting. You’ll see a text box labeled “Redirection rules (optional)”. Copy and paste the code in here and make sure you edit the “myhostname.com” with what your domain actually is.
After this rule is added, when you refresh, you won’t get a 404 error anymore. Instead you’ll be routed back to your home route, whichever component you have linked from your ‘/’ route. This is an improvement, but things could be better. Ideally we want to route back to the same component and my Nav isn’t reflecting which page I’m on properly, which is a different problem.
Back to Mike Biek’s post, he says to use ‘createBrowserHistory’ to work with the path directly. I tried to get this to work, but I could not, and after some further googling and reading found that createBrowserHistory had been abandoned and that devs should work with the location prop directly. I couldn’t find any examples of someone using it for this purpose but, I had some thoughts of my own. 
I had to do some experimenting with the location object to figure it out. Basically the location object is created and passed into props anytime you follow a Route and it will be available in the component when it is instantiated. Inside the location object there is a 'pathname' key and a 'hash' key, the path from the address bar will be the 'pathname' value but if there is a ‘#’ in the address then everything from the ‘#’ on will be the 'hash' value.
The layout of my portfolio site is simple, there are only two items in my Nav, one is 'Projects', which displays by default, the other is an 'About' page. My first iteration of a solution was to put a function in my default component that would read the location object and if there was a hash variable and it contained ‘#!/About’, using a Redirect from react-router switch back to the About component, otherwise it could stay on Projects.
It looked like this…

const path = props.history.location

    const checkPath = () => {
        if (path.hash) {
            if (path.hash.includes('#!/About')) {
                return <Redirect push to='/About'></Redirect>
            }
        }
    }

Now, things would not crash when a refresh button was clicked. If you were on About then you would get back to About and if you were on Projects, you would end up back on Projects. The transition would happen quick enough that it was basically invisible to the user. But my Nav was not reflecting which component was active properly. So, the other thing I needed to do at this stage was hoist control over displaying which Nav element was ‘active’ to the top level ‘App’ component. I created a state named ‘active’ which could hold which Nav element would be active, then created a function to set the active element and I passed down the function to the Nav and Project components.
Like so…

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      active: 'Projects'
    }
  }
 setActive = (str) => {
    this.setState({ active: str })
  }

This didn’t exactly work yet, things no longer broke when the refresh button was clicked, good, but the forward and back buttons would still screw up the active Nav item and it was not a very flexible system. The redirect worked fine with just two pages but, if I added more, I would need a series of ifs or a switch statement and that felt clunky.
I decided to rework it again. First, I made a helper.js file and put my checkPath function in there. That way I could link to it from every component. I set it up so it would work from any page. If there is a 'hash' value in the location, the function grabs the hash path as a variable and strips out the extra characters, then redirects to that item. Also, the same function will set the active Nav item in a similar fashion.
Looks like this…

import React from 'react'
import { Redirect } from 'react-router-dom'

export function checkPath(props) {
   const path = props.state.history.location
   if (path.hash) {
       let active = path.hash.replace(/\/|!|#/g, '')
       active = '/' + active
       return <Redirect push to={active}></Redirect>
   }
   if (path.pathname) {
       let active = path.pathname.replace(/\//g, '')
       props.setActive(active)
   }
}

 Next, I pass the setActive function down to each component in its Route declaration and, inside the component, import the setPath helper function, then call it inside the return statement of the component. You have to make sure you pass the props into the checkPath function so that it can use the setActive function. Also, my setActive function needed a little more work to make sure that the right Nav item was set active on the ‘/’ route and to keep the setState call from starting an endless loop. You’ll see in my code below.
One more issue to resolve, now that I have no '404' error page displaying server-side, I need to set up a default or catch-all route that will display a message on a bad link or manually typed path that doesn’t match. This just requires adding a final Route which will display a component, I'm calling 'NoMatch', if none of the other Routes match. One more note on my code, I'm sending props down through the Route as 'state', you could call them whatever you want but if you don't implicitly pass them down then they will not be accessible in the component. I learned this the hard way, with much frustration, on a different project.
Putting all this together looks like…
In App.js…

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      active: 'Projects'
    }
  }

  setActive = (str) => {
    if (!str) {
      str = 'Projects'
    }
    if (this.state.active !== str) {
      this.setState({ active: str })
    }
  }

  render() {
    return (
      <div className="App flex-container-column">
        <div className="container flex-container-column">
          <Header></Header>
          <Nav active={this.state.active}></Nav>
          <Switch>
            <Route path="/" exact render={props => <Projects setActive={this.setActive} state={props} />} />
            <Route path="/About/" render={props => <Home setActive={this.setActive} state={props} />} />
            <Route path="/Projects/" render={props => <Projects setActive={this.setActive} state={props} />} />
            <Route path="/" render={props => <NoMatch setActive={this.setActive} state={props} />} />
          </Switch>
        </div>
        <Footer></Footer>
      </div>
    );
  }
}

In my Components, it. looks like this...

import { checkPath } from '../helpers'

function NoMatch(props) {
    return (

        <div>
            <div className="flex-container-column centered">
                {checkPath(props)}
                <div className='short'>
                    <p>Hmm, There doesn't seem to be anything here... Go back <Link to="/">Home?</Link></p>
                </div>
            </div>

        </div>
    )
}

This solution works pretty well but its not perfect. If a user clicks refresh, the back button will not be able to get past that point because it will endlessly go to the hashed route and then redirect to the non-hashed route. Also, SEO won't find anything past the first page unless you set up some kind of server-side routing. But, this is a pretty good solution for little single page apps, portfolios and other simple projects.
James C Rodgers is an AWS Certified Cloud Practitioner and a recent graduate of General Assemb.ly Full Stack Software Engineering Immersive Remote program
Photo credit Yuliya Kosolapova on Unsplash

Top comments (0)