325

I am using S3 to host a javascript app that will use HTML5 pushStates. The problem is if the user bookmarks any of the URLs, it will not resolve to anything. What I need is the ability to take all url requests and serve up the root index.html in my S3 bucket, rather than just doing a full redirect. Then my javascript application could parse the URL and serve the proper page.

Is there any way to tell S3 to serve the index.html for all URL requests instead of doing redirects? This would be similar to setting up apache to handle all incoming requests by serving up a single index.html as in this example: https://stackoverflow.com/a/10647521/1762614. I would really like to avoid running a web server just to handle these routes. Doing everything from S3 is very appealing.

Community
  • 1
  • 1
Mark Nutter
  • 5,449
  • 4
  • 15
  • 12

18 Answers18

486

It's very easy to solve it without url hacks, with CloudFront help.

  • Create S3 bucket, for example: react
  • Create CloudFront distributions with these settings:
    • Default Root Object: index.html
    • Origin Domain Name: S3 bucket domain, for example: react.s3.amazonaws.com
  • Go to Error Pages tab, click on Create Custom Error Response:
    • HTTP Error Code: 403: Forbidden (404: Not Found, in case of S3 Static Website)
    • Customize Error Response: Yes
    • Response Page Path: /index.html
    • HTTP Response Code: 200: OK
    • Click on Create
lenaten
  • 5,065
  • 1
  • 13
  • 9
  • and what is not so obvious you keep location so you can do "natural" routing. – Lukasz Marek Sielski Oct 21 '16 at 12:53
  • 9
    this worked like a charm for me, only the custom error code I needed was 404, not 403 – Jeremy S. Oct 24 '16 at 00:57
  • It's true, because your cloud front origin is s3 static website and not simple s3 bucket.. – lenaten Oct 24 '16 at 12:51
  • 4
    A bit of a hack, but works great :) It'd be nice if CloudFront just let us map a range of paths to a S3 file (without a redirect). – Bob Dec 15 '16 at 06:36
  • 1
    This won't work if your app shares the host with other content. There is no way to determine if example.com/something-not-found belongs to some SPA or is truly 404. – Cory Mawhorter Jan 12 '17 at 00:06
  • @CoryMawhorter why not? cloud front need to modify only the 403 response (just point cloud front to s3 bucket instead of s3 static website) – lenaten Jan 12 '17 at 10:01
  • 2
    @NathanielMaman because you could have two origins in your cf distrib. `.com/app` (s3) and `.com/auth` (ec2/whatever). Error responses are top-level, so there is no way to tell the diff between an s3 403 and a 403 from /auth or anywhere else. The private, preview-only Lambda@Edge is the fix and is a ridiculous solution to mod_rewrite. – Cory Mawhorter Jan 13 '17 at 16:23
  • 1
    tl;dr;. @NathanielMaman's solution is the best option available right now, but is less than ideal and has drawbacks. – Cory Mawhorter Jan 13 '17 at 16:25
  • 1
    It was hard for me to find the error pages. You first have to create the distribution and then edit it. There you can see a tab called "Error Pages" – hcarreras Feb 21 '17 at 09:54
  • Thanks Nathaniel. For those who are seeing an XML document when you navigate to your new CF url; you forgot to specify the **Default Root Object**. I missed it in the instructions and ended up scratching my head for a while! – Rick Mar 20 '17 at 03:29
  • If you are still getting an XML error when requesting non-empty paths from CloudFront, you might need to edit your S3 bucket ACL: go to Permissions > Manage public permissions and add Read to Everyone. This allows unauthenticated users to read missing objects from your bucket and get the proper error triggering the CloudFront Error Page Response. – Carl G Mar 31 '17 at 06:44
  • Do I need to set TTL as 0? – foxiris Feb 18 '18 at 15:05
  • No, it’s not required. – lenaten Feb 19 '18 at 16:40
  • I have a similar issue but I am reluctant for this solution as this hack will cause the index.html to be loaded in case of an actual 404 too. Any solution for that? – Hassan Mar 21 '18 at 13:46
  • Yep. Just replace the s3 static website with the basic s3 bucket url and create error page for 403 error instead of 404. – lenaten Mar 21 '18 at 18:12
  • It even pass the URL to index.html, so the requested page will appear instead of index.html. Thanks! – Arman Fatahi Mar 28 '18 at 10:06
  • How would this work with multiple SPAs in a single S3 bucket? e.g. `/myapp1/index.html` and `/myapp2/index.html`. – BBlackwo Jul 19 '18 at 05:59
  • @lenaten sorry I meant is there a way to get this to work with multiple SPAs? – BBlackwo Jul 21 '18 at 02:51
  • You need a single CloudFront distribution per SPA. – lenaten Jul 23 '18 at 20:17
  • This should be marked as correct answer. Other didn't work for me, this one worked well and immediately. – Sean Dec 12 '18 at 01:57
  • For me there was one more hop. The angular app was getting Id_Token from OpenIDConnect provider whichin my caes is IdentityServer4, I had to redirect error document to index.html in S3 static web hosting config – Afshin Teymoori Feb 17 '19 at 11:54
  • This is the method officially recommended by Facebook/ReactJS. They reference this article for details: https://medium.com/@omgwtfmarc/deploying-create-react-app-to-s3-or-cloudfront-48dae4ce0af – Tyler Apr 11 '19 at 17:34
  • 6
    This is not working for me. This solution always redirects to the home page and not the correct pages... – Jim Nov 05 '19 at 00:20
  • @Jim that's the point.. you're probably not processing the URL in your frontend correctly – Ruben Serrate Feb 19 '20 at 15:36
  • 3
    This solution leads to a "slow initial server response time" error in pagespeed or other popular tests. Every time, the cloudfront request tries to find it, errors out, and redirects to error page. This happens for every page load and seem quite wasteful. Is there another approach that doesn't take so much server time? (A more performant solution) This approach adds ~500ms to initial server response time, effectively overriding the CDN. – Aditya Anand Aug 04 '20 at 09:38
  • What if a sub folder of my bucket website has client side routing (SPA) and not the route of my website bucket ? – nabeelfarid Jun 23 '21 at 12:28
  • 1
    I noticed CloudFront shipped a feature that allows you to modify the response code i.e. S3 => 403/404 => CF => 200 => User. That makes this a tiny bit more usable, but it still suffers from the issue of taking over the entire status code because error page responses are global to the cf distribution, and not per-behavior. This is because AWS wants you to use functions. Which are brittle, and suck for stuff like this. – Cory Mawhorter Oct 05 '21 at 21:50
212

The way I was able to get this to work is as follows:

In the Edit Redirection Rules section of the S3 Console for your domain, add the following rules:

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

This will redirect all paths that result in a 404 not found to your root domain with a hash-bang version of the path. So http://yourdomainname.com/posts will redirect to http://yourdomainname.com/#!/posts provided there is no file at /posts.

To use HTML5 pushStates however, we need to take this request and manually establish the proper pushState based on the hash-bang path. So add this to the top of your index.html file:

<script>
  history.pushState({}, "entry page", location.hash.substring(1));
</script>

This grabs the hash and turns it into an HTML5 pushState. From this point on you can use pushStates to have non-hash-bang paths in your app.

Mario Petrovic
  • 7,500
  • 14
  • 42
  • 62
Mark Nutter
  • 5,449
  • 4
  • 15
  • 12
  • 5
    This solution works great! In fact angularjs will automatically do the history pushState if the html mode is configured. – wcandillon Oct 11 '13 at 17:20
  • 1
    This will fail with older version of IE if you have GET params included in your urls, correct? How do you get around that issue? – clexmond Jan 21 '14 at 16:14
  • 3
    Thanks! This worked well for me on backbone with a small tweak. I added in a check for older browsers: `` – AE Grey Feb 25 '14 at 23:41
  • Anyone replicated the rule using modrewrite middleware for connect? – qmo Mar 19 '15 at 16:15
  • @MarkNutter, this is the correct solution. Please, accept it. Stefan's answer is misleading. – Zanon Aug 09 '15 at 14:08
  • 10
    Succeeded with `react-router` with that solution using HTML5 pushStates and `#/` – Felix D. Sep 24 '15 at 02:28
  • 1
    @JWarner did you ever found out a solution for Safari, I still find this is the case – Santthosh Sep 30 '15 at 20:08
  • Thanks, this method works (except for in Safari). To mimic this locally using grunt, use connect-modrewrite, use the following rewrite-rule: `^/([^\\.]+)$ /#!/$1 [NC,NE,R=302]` – Motin Nov 02 '15 at 09:13
  • 6
    It does not workin safari and is a big problem with the deployment strategy. Writing a small js to redirect is kind of shabby approach. Also, the number of redirects is a problem too. I am trying to figure if there is a way for all S3 urls to point to index.html always. – moha297 Feb 12 '16 at 03:24
  • 1
    be careful with this approach if you try to use subdomains at some point. since you have to hardcode the host name into the redirect url that doesn't work anymore. In that case see solution by moha297 – Andreas Mar 07 '16 at 20:30
  • This appears to be working in Safari now. I coupled the Amazon redirect with Michael Ahlers solution for react router. Safari appears to be working – Kyeotic Mar 24 '16 at 15:55
  • @Andreas here is a solution for multiple apps in subfolders using your own nginx proxy, works with subdomains too and it doesn't need cloudfront http://stackoverflow.com/questions/16267339/s3-static-website-hosting-route-all-paths-to-index-html/37371897#37371897 – Andrew Arnautov May 23 '16 at 05:41
  • I sure wish I didn't have to do it, but this also works with Angular2, with `#/`. I'm using CloudFront so I also had to point the CloudFront distribution to the *website* endpoint and not the original S3 REST endpoint. – hartz89 Jun 03 '16 at 20:37
  • This approach is necessary if you are using Lambda @ Edge to modify the response (for instance, to add HSTS headers), because Lambda will not run on a 4xx response. – Jonathon Hill Oct 03 '17 at 18:02
  • This solution worked for me! Is there any docs I can refer to for understanding the syntax of this? – Vince Mar 08 '18 at 03:32
  • There is one best alternative, you can use cloudfront edge with lambda function to handle all routing to index.html – Priyabrata Apr 26 '20 at 15:40
  • I chose this solution because my app is server-less and not using cloud-front. But every time I refresh, the main page flickers. Is there any way to make it not flicker? – hyeogeon Nov 01 '22 at 05:53
146

There are few problems with the S3/Redirect based approach mentioned by others.

  1. Mutliple redirects happen as your app's paths are resolved. For example: www.myapp.com/path/for/test gets redirected as www.myapp.com/#/path/for/test
  2. There is a flicker in the url bar as the '#' comes and goes due the action of your SPA framework.
  3. The seo is impacted because - 'Hey! Its google forcing his hand on redirects'
  4. Safari support for your app goes for a toss.

The solution is:

  1. Make sure you have the index route configured for your website. Mostly it is index.html
  2. Remove routing rules from S3 configurations
  3. Put a Cloudfront in front of your S3 bucket.
  4. Configure error page rules for your Cloudfront instance. In the error rules specify:

    • Http error code: 404 (and 403 or other errors as per need)
    • Error Caching Minimum TTL (seconds) : 0
    • Customize response: Yes
    • Response Page Path : /index.html
    • HTTP Response Code: 200

      1. For SEO needs + making sure your index.html does not cache, do the following:
    • Configure an EC2 instance and setup an nginx server.

    • Assign a public ip to your EC2 instance.
    • Create an ELB that has the EC2 instance you created as an instance
    • You should be able to assign the ELB to your DNS.
    • Now, configure your nginx server to do the following things: Proxy_pass all requests to your CDN (for index.html only, serve other assets directly from your cloudfront) and for search bots, redirect traffic as stipulated by services like Prerender.io

I can help in more details with respect to nginx setup, just leave a note. Have learnt it the hard way.

Once the cloud front distribution update. Invalidate your cloudfront cache once to be in the pristine mode. Hit the url in the browser and all should be good.

moha297
  • 1,902
  • 1
  • 16
  • 16
  • 6
    since S3 responds with a 403 Forbidden when a file doesn't exist, I think step 4 above has to be duplicated for Http error code 403 as well – Andreas Mar 07 '16 at 20:46
  • I am not sure about it, but I will add the same. – moha297 Mar 08 '16 at 21:10
  • Your solution works great, no # blinks, CF returns 200 with index.html. – Ivan Kaplin Apr 22 '16 at 09:49
  • What is "this approach" that you mention on the first line of your answer? Do you mean [Mark Nutter's answer](http://stackoverflow.com/a/16877231)? – rjmunro Jul 25 '16 at 14:02
  • 4
    For me this is the only answer that results in an expected (accepted) behavior – mabe.berlin Jul 25 '16 at 21:08
  • Mark - I started writing this after reading responses which were already added for hosting using S3 bucket and Redirect rules. 'This' in my first sentence referred to that. I will update the content a bit to help future clarity. – moha297 Jul 26 '16 at 15:06
  • Can anybody explain about 3rd point? (Put a Cloudfront in front of your S3 bucket.). – Parthiv Aug 12 '16 at 04:43
  • @kpp The Cloudfront setup in AWS can use S3 as the source for the CDN being provisioned. You can setup your cloudfront to pick from a url or an S3 bucket. – moha297 Aug 13 '16 at 21:10
  • @moha297 Rather than doing point 5 here, can I just invalidate my `index.html` every time it is updated so that cloudfront does not cache it? – mrudult Aug 14 '16 at 11:50
  • 1
    I would not recommend. Generally, when you update your javascript or css the versioning signature on packages will get updated. However, the clients which are already running your application will have their index.html cached already and would not get the updated index.html. Hence, will try to reference older versions of your JS and CSS (still available on their local cache/CDN). This is sub-optimal. You can try to change your CDN config to change the expires headers for index.html so that it is not cacheable. – moha297 Aug 14 '16 at 19:22
  • This is great. But why set the cache TTL to 0? The default 300 seconds seems reasonable. – Hamish Moffatt Aug 16 '16 at 01:02
  • I prefer 0. No issues in resolving error scenarios as early as possible for users in my opinion. :) – moha297 Aug 17 '16 at 07:23
  • 1
    @moha297 One more question. Why not directly server index.html from the EC2 instance itself rather than proxying it? – mrudult Aug 21 '16 at 09:57
  • You can do that. But then you will have to push code the EC2 boxes. You can remove S3 and push all your code to an EC2 instance(s). Map your CDN to read from the EC2 rather than S3. In my case: I was running a node app on my EC2 servers with nginx already running there. I chose to leverage them for my redirection and keep my S3 bucket setup in place. Note: I started off with deploying to S3 initially with the redirection rules but found the solution pretty irritating - so started doing more after that and reached the solution pointed here. – moha297 Aug 22 '16 at 14:54
  • @moha297 Great answer! But I'm having trouble with point 5 - connecting my nginx with CloudFront to avoid caching my index.html. Can you share your `nginx.conf`, or the directives that you used to properly avoid that caching? I tried using `location / { proxy_pass http://asdf1234.cloudfront.net/; }` but I'm getting a 403 from CloudFront – modulitos Sep 07 '16 at 19:52
  • 1
    @moha297 in point 5 are you basically configuring your site to fetch from nginx which then proxies from the CDN (except for index.html and crawler requests)? If that's the case, wouldn't you lose the benefit of CDN edge servers? – Rahul Patel Dec 06 '16 at 18:33
  • @RahulPatel I mention 'for index.html only, serve other assets directly from your cloudfront)' Your build should have taken care of making sure your cdn urls are configured for static assets liks JS and CSS. You should never serve index.html from a CDN. – moha297 Dec 06 '16 at 21:55
  • 2
    @moha297 can you please explain this comment: "You should never serve index.html from a CDN"? I don't see the problem with serving index.html from S3 with CloudFront. – Carl G Mar 31 '17 at 05:18
  • @CarlG - If you serve index.html via CDN - it will get cached on browsers of your users as well as CDN servers. So, when you update your code by changing content in javascript, css, images etc or even the index.html itself, the users will continue to see the old application via the CDN. If you invalidate the CDN to solve this problem - the existing users (who have a cached version of index.html in their browser) will still not see your updated application. – moha297 Apr 01 '17 at 06:52
  • Why set the error caching TTL to 0? That will result in a lot more hits to your origin (and potentially a lot more trouble if it's down) if your users are regularly going to something other than /, via bookmarks, refresh etc. – Hamish Moffatt Apr 06 '17 at 01:08
  • If my production is down then I dont want errors to not be cached on my CDN. But the choice is subjective. You can put any value you feel is appropriate as well. – moha297 Apr 06 '17 at 20:51
  • I still don't understand point 3 - how to do that? I've already selected S3 bucket as a source for the cloud front. If that's the case then it's not working :( – Andrey Popov Aug 28 '17 at 15:34
  • 1
    See https://stackoverflow.com/a/10622078/4185989 for more info on how a TTL of 0 gets treated (short version: it gets cached by Cloudfront but a `If-Modified-Since` GET request is sent to the origin) - may be a useful consideration for people not wanting to set up a server like in step 5. – jmq Dec 29 '17 at 16:51
18

It's tangential, but here's a tip for those using Rackt's React Router library with (HTML5) browser history who want to host on S3.

Suppose a user visits /foo/bear at your S3-hosted static web site. Given David's earlier suggestion, redirect rules will send them to /#/foo/bear. If your application's built using browser history, this won't do much good. However your application is loaded at this point and it can now manipulate history.

Including Rackt history in our project (see also Using Custom Histories from the React Router project), you can add a listener that's aware of hash history paths and replace the path as appropriate, as illustrated in this example:

import ReactDOM from 'react-dom';

/* Application-specific details. */
const route = {};

import { Router, useRouterHistory } from 'react-router';
import { createHistory } from 'history';

const history = useRouterHistory(createHistory)();

history.listen(function (location) {
  const path = (/#(\/.*)$/.exec(location.hash) || [])[1];
  if (path) history.replace(path);
});

ReactDOM.render(
  <Router history={history} routes={route}/>,
  document.body.appendChild(document.createElement('div'))
);

To recap:

  1. David's S3 redirect rule will direct /foo/bear to /#/foo/bear.
  2. Your application will load.
  3. The history listener will detect the #/foo/bear history notation.
  4. And replace history with the correct path.

Link tags will work as expected, as will all other browser history functions. The only downside I've noticed is the interstitial redirect that occurs on initial request.

This was inspired by a solution for AngularJS, and I suspect could be easily adapted to any application.

Community
  • 1
  • 1
Michael Ahlers
  • 616
  • 7
  • 21
16

I see 4 solutions to this problem. The first 3 were already covered in answers and the last one is my contribution.

  1. Set the error document to index.html.
    Problem: the response body will be correct, but the status code will be 404, which hurts SEO.

  2. Set the redirection rules.
    Problem: URL polluted with #! and page flashes when loaded.

  3. Configure CloudFront.
    Problem: all pages will return 404 from origin, so you need to chose if you won't cache anything (TTL 0 as suggested) or if you will cache and have issues when updating the site.

  4. Prerender all pages.
    Problem: additional work to prerender pages, specially when the pages changes frequently. For example, a news website.

My suggestion is to use option 4. If you prerender all pages, there will be no 404 errors for expected pages. The page will load fine and the framework will take control and act normally as a SPA. You can also set the error document to display a generic error.html page and a redirection rule to redirect 404 errors to a 404.html page (without the hashbang).

Regarding 403 Forbidden errors, I don't let them happen at all. In my application, I consider that all files within the host bucket are public and I set this with the everyone option with the read permission. If your site have pages that are private, letting the user to see the HTML layout should not be an issue. What you need to protect is the data and this is done in the backend.

Also, if you have private assets, like user photos, you can save them in another bucket. Because private assets need the same care as data and can't be compared to the asset files that are used to host the app.

Community
  • 1
  • 1
Zanon
  • 29,231
  • 20
  • 113
  • 126
  • 1
    and your site has a great example of use with to prerender for all pages. https://zanon.io/posts/angularjs-how-to-create-a-spa-crawlable-and-seo-friendly .- Thank you – frekele Jun 01 '17 at 02:33
  • Does this fourth approach address the user reloading the pushState URL? It handles navigation just fine but on a reload, it will still reach the server. – Alpha Jun 11 '17 at 17:24
  • @Alpha, I'm not sure if I have understood your question correctly, but on a reload, it would act as a new request. S3 would receive the request and return the prerendered page again. There is no server in this case. – Zanon Jun 11 '17 at 21:17
  • @Zanon Ah, I think I understand. I thought it was meant to cache prerendered pages and avoid going for the S3 unexistent resources. This would leave out routes that have dynamic elements, right? – Alpha Jun 11 '17 at 23:34
  • @Alpha you can use CloudFront to cache, but S3 must always have a prerendered HTML for each address. – Zanon Jun 11 '17 at 23:44
  • @Alpha regarding routes with dynamic elements, do you mean something like [this](https://stackoverflow.com/questions/25785579/is-angular-routing-template-url-support-for-cshtml-file-in-asp-net-mvc-5-projec/35066142#35066142)? (where the template URL will execute a function in the server). In this case, it could also be prerendered, however a new server request would indeed be executed. – Zanon Jun 11 '17 at 23:44
  • 1
    @Zanon I finally understand -- thanks! Yes, that's what I meant. :) – Alpha Jun 11 '17 at 23:46
15

The easiest solution to make Angular 2+ application served from Amazon S3 and direct URLs working is to specify index.html both as Index and Error documents in S3 bucket configuration.

enter image description here

Sergey Kandaurov
  • 2,626
  • 3
  • 24
  • 35
  • 13
    This is the same answer of [this heavily downvoted answer](http://stackoverflow.com/a/16979716/1476885). It works fine, but only at for the `body` of the response. The status code will be 404 and it will hurt SEO. – Zanon May 14 '17 at 10:58
  • Because this only work for the `body` if you have any scripts that you importing in the `head` they will not work when you directly hit any of the sub-routes on your website – Mo Hajr Jan 11 '19 at 10:53
14

I ran into the same problem today but the solution of @Mark-Nutter was incomplete to remove the hashbang from my angularjs application.

In fact you have to go to Edit Permissions, click on Add more permissions and then add the right List on your bucket to everyone. With this configuration, AWS S3 will now, be able to return 404 error and then the redirection rule will properly catch the case.

Just like this : enter image description here

And then you can go to Edit Redirection Rules and add this rule :

<RoutingRules>
    <RoutingRule>
        <Condition>
            <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
        </Condition>
        <Redirect>
            <HostName>subdomain.domain.fr</HostName>
            <ReplaceKeyPrefixWith>#!/</ReplaceKeyPrefixWith>
        </Redirect>
    </RoutingRule>
</RoutingRules>

Here you can replace the HostName subdomain.domain.fr with your domain and the KeyPrefix #!/ if you don't use the hashbang method for SEO purpose.

Of course, all of this will only work if you have already have setup html5mode in your angular application.

$locationProvider.html5Mode(true).hashPrefix('!');
Mario Petrovic
  • 7,500
  • 14
  • 42
  • 62
davidbonachera
  • 4,416
  • 1
  • 21
  • 29
  • my only issue with this is that you have a flash of hashbang, which you don't have with something like an nginx rule. User is on a page and refreshes: /products/1 -> #!/products/1 -> /products/1 – creamcheese Sep 29 '15 at 22:22
  • 2
    I think you should add a rule for a 403 error rather than grant list permissions to everyone. – Hamish Moffatt Apr 06 '17 at 01:11
14

You can now do this with Lambda@Edge to rewrite the paths

Here is a working lambda@Edge function:

  1. Create a new Lambda function, but use one of the pre-existing Blueprints instead of a blank function.
  2. Search for “cloudfront” and select cloudfront-response-generation from the search results.
  3. After creating the function, replace the content with the below. I also had to change the node runtime to 10.x because cloudfront didn't support node 12 at the time of writing.
'use strict';
exports.handler = (event, context, callback) => {
    
    // Extract the request from the CloudFront event that is sent to Lambda@Edge 
    var request = event.Records[0].cf.request;

    // Extract the URI from the request
    var olduri = request.uri;

    // Match any '/' that occurs at the end of a URI. Replace it with a default index
    var newuri = olduri.replace(/\/$/, '\/index.html');
    
    // Log the URI as received by CloudFront and the new URI to be used to fetch from origin
    console.log("Old URI: " + olduri);
    console.log("New URI: " + newuri);
    
    // Replace the received URI with the URI that includes the index page
    request.uri = newuri;

    return callback(null, request);
};

In your cloudfront behaviors, you'll edit them to add a call to that lambda function on "Viewer Request" enter image description here

Full Tutorial: https://aws.amazon.com/blogs/compute/implementing-default-directory-indexes-in-amazon-s3-backed-amazon-cloudfront-origins-using-lambdaedge/

Mario Petrovic
  • 7,500
  • 14
  • 42
  • 62
Loren
  • 9,783
  • 4
  • 39
  • 49
10

Similar to another answer using Lambda@Edge, you can use Cloudfront Functions (which as of August 2021 are part of the free tier).

My URL structure is like this:

  • example.com - React SPA hosted on S3
  • example.com/blog - Blog hosted on an EC2

Since I'm hosting the blog on the same domain as the SPA, I couldn't use the suggested answer of having Cloudfront 403/404 error pages using a default error page.

My setup for Cloudfront is:

  1. Set two origins (S3, and my EC2 instance via an Elastic ALB)
  2. Set up two behaviors:
    • /blog
    • default (*)
  3. Create the Cloudfront Function
  4. Setup the Cloudfront function as the "Viewer request" of the default (*) behavior

I'm using this Cloudfront function based on the AWS example. This may not work for all cases, but my URL structure for the React app doesn't contain any ..

function handler(event) {
    var request = event.request;
    var uri = request.uri;
       
    // If the request is not for an asset (js, png, etc), render the index.html
    if (!uri.includes('.')) {
        request.uri = '/index.html';
    }
      
    return request;
}

Edit June 2022

I've updated my function to be this:

function handler(event) {
    var request = event.request;
    var uri = request.uri;
    
    // Check whether the URI is missing a file name.
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    } 
    // Check whether the URI is missing a file extension.
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    return request;
}
claptimes
  • 1,615
  • 15
  • 20
7

Was looking for the same kind of problem. I ended up using a mix of the suggested solutions described above.

First, I have an s3 bucket with multiple folders, each folder represents a react/redux website. I also use cloudfront for cache invalidation.

So I had to use Routing Rules for supporting 404 and redirect them to an hash config:

<RoutingRules>
    <RoutingRule>
        <Condition>
            <KeyPrefixEquals>website1/</KeyPrefixEquals>
            <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
        </Condition>
        <Redirect>
            <Protocol>https</Protocol>
            <HostName>my.host.com</HostName>
            <ReplaceKeyPrefixWith>website1#</ReplaceKeyPrefixWith>
        </Redirect>
    </RoutingRule>
    <RoutingRule>
        <Condition>
            <KeyPrefixEquals>website2/</KeyPrefixEquals>
            <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
        </Condition>
        <Redirect>
            <Protocol>https</Protocol>
            <HostName>my.host.com</HostName>
            <ReplaceKeyPrefixWith>website2#</ReplaceKeyPrefixWith>
        </Redirect>
    </RoutingRule>
    <RoutingRule>
        <Condition>
            <KeyPrefixEquals>website3/</KeyPrefixEquals>
            <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
        </Condition>
        <Redirect>
            <Protocol>https</Protocol>
            <HostName>my.host.com</HostName>
            <ReplaceKeyPrefixWith>website3#</ReplaceKeyPrefixWith>
        </Redirect>
    </RoutingRule>
</RoutingRules>

In my js code, I needed to handle it with a baseName config for react-router. First of all, make sure your dependencies are interoperable, I had installed history==4.0.0 wich was incompatible with react-router==3.0.1.

My dependencies are:

  • "history": "3.2.0",
  • "react": "15.4.1",
  • "react-redux": "4.4.6",
  • "react-router": "3.0.1",
  • "react-router-redux": "4.0.7",

I've created a history.js file for loading history:

import {useRouterHistory} from 'react-router';
import createBrowserHistory from 'history/lib/createBrowserHistory';

export const browserHistory = useRouterHistory(createBrowserHistory)({
    basename: '/website1/',
});

browserHistory.listen((location) => {
    const path = (/#(.*)$/.exec(location.hash) || [])[1];
    if (path) {
        browserHistory.replace(path);
    }
});

export default browserHistory;

This piece of code allow to handle the 404 sent by the sever with an hash, and replace them in history for loading our routes.

You can now use this file for configuring your store ans your Root file.

import {routerMiddleware} from 'react-router-redux';
import {applyMiddleware, compose} from 'redux';

import rootSaga from '../sagas';
import rootReducer from '../reducers';

import {createInjectSagasStore, sagaMiddleware} from './redux-sagas-injector';

import {browserHistory} from '../history';

export default function configureStore(initialState) {
    const enhancers = [
        applyMiddleware(
            sagaMiddleware,
            routerMiddleware(browserHistory),
        )];

    return createInjectSagasStore(rootReducer, rootSaga, initialState, compose(...enhancers));
}
import React, {PropTypes} from 'react';
import {Provider} from 'react-redux';
import {Router} from 'react-router';
import {syncHistoryWithStore} from 'react-router-redux';
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
import getMuiTheme from 'material-ui/styles/getMuiTheme';
import variables from '!!sass-variable-loader!../../../css/variables/variables.prod.scss';
import routesFactory from '../routes';
import {browserHistory} from '../history';

const muiTheme = getMuiTheme({
    palette: {
        primary1Color: variables.baseColor,
    },
});

const Root = ({store}) => {
    const history = syncHistoryWithStore(browserHistory, store);
    const routes = routesFactory(store);

    return (
        <Provider {...{store}}>
            <MuiThemeProvider muiTheme={muiTheme}>
                <Router {...{history, routes}} />
            </MuiThemeProvider>
        </Provider>
    );
};

Root.propTypes = {
    store: PropTypes.shape({}).isRequired,
};

export default Root;

Hope it helps. You'll notice with this configuration I use redux injector and an homebrew sagas injector for loading javascript asynchrounously via routing. Don't mind with theses lines.

Mario Petrovic
  • 7,500
  • 14
  • 42
  • 62
Guillaume Cisco
  • 2,859
  • 24
  • 25
7

Go to your bucket's Static website hosting setting and set Error document to index.html.

Lordd Lazaro
  • 71
  • 1
  • 2
  • 2
    You really shouldn't be using S3's built-in static website hosting these days. All new buckets should be configured with Block Public Access enabled. Use Cloudfront for this. – forresthopkinsa Mar 19 '22 at 06:27
  • agree with @forresthopkinsa 's comment. – Safvan CK Jun 10 '22 at 19:20
  • 1
    agree with @forresthopkinsa 's comment. Setting up "Error document" to "index.html" is easy and solves the above problem, but that is inefficient. Take a look at this AWS blog post: https://aws.amazon.com/blogs/networking-and-content-delivery/amazon-s3-amazon-cloudfront-a-match-made-in-the-cloud/ NB: link is not related to the question asked – Safvan CK Jun 10 '22 at 19:29
5

since the problem is still there I though I throw in another solution. My case was that I wanted to auto deploy all pull requests to s3 for testing before merge making them accessible on [mydomain]/pull-requests/[pr number]/
(ex. www.example.com/pull-requests/822/)

To the best of my knowledge non of s3 rules scenarios would allow to have multiple projects in one bucket using html5 routing so while above most voted suggestion works for a project in root folder, it doesn't for multiple projects in own subfolders.

So I pointed my domain to my server where following nginx config did the job

location /pull-requests/ {
    try_files $uri @get_files;
}
location @get_files {
    rewrite ^\/pull-requests\/(.*) /$1 break;
    proxy_pass http://<your-amazon-bucket-url>;
    proxy_intercept_errors on;
    recursive_error_pages on;
    error_page 404 = @get_routes;
}

location @get_routes {
    rewrite ^\/(\w+)\/(.+) /$1/ break;
    proxy_pass http://<your-amazon-bucket-url>;
    proxy_intercept_errors on;
    recursive_error_pages on;
    error_page 404 = @not_found;
}

location @not_found {
    return 404;
}

it tries to get the file and if not found assumes it is html5 route and tries that. If you have a 404 angular page for not found routes you will never get to @not_found and get you angular 404 page returned instead of not found files, which could be fixed with some if rule in @get_routes or something.

I have to say I don't feel too comfortable in area of nginx config and using regex for that matter, I got this working with some trial and error so while this works I am sure there is room for improvement and please do share your thoughts.

Note: remove s3 redirection rules if you had them in S3 config.

and btw works in Safari

Andrew Arnautov
  • 345
  • 3
  • 5
  • hi.. thanks for the solution... where do you put this nginx conf file for s3 deployment. is it the same as elastic beanstalk where I need to create .exextensions folder and put it in proxy.config file? – user3124360 Jan 17 '17 at 20:52
  • @user3124360 Not sure about elastic beanstack, but in my case I point my domain name to ec2 instance and have nginx config there. So request goes CLIENT -> DNS -> EC2 -> S3 -> CLIENT. – Andrew Arnautov Jan 23 '17 at 11:02
  • yeah I am doing something very similar ... with nginx config here https://github.com/davezuko/react-redux-starter-kit/issues/1099 ... thanks for posting your conf file .. i see how develop this EC2 -> S3 connection now – user3124360 Jan 23 '17 at 17:52
3

The problem with most of the proposed solutions, especially the most popular answer, is that you never get a 404 error for non-existent backend resources. If you want to continue getting 404, refer to this solution

  1. I added a root path to all my routes (that's new to this solution) e.g. all my route-paths start with the same front end root...
    in place of paths /foo/baz, /foo2/baz I now have /root/foo/baz , /root/foo2/baz paths.
  2. The choice of the front-end root is such that it does not conflict with any real folder or path at the back-end.
  3. Now I can use this root to create simple redirection rules anywhere, I like. All my redirection changes will be impacting only this path and everything else will be as earlier.

This is the redirection rule I added in my s3 bucket

[
    {
        "Condition": {
            "HttpErrorCodeReturnedEquals": "404",
            "KeyPrefixEquals": "root/"
        },
        "Redirect": {
            "HostName": "mydomain.com",
            "ReplaceKeyPrefixWith": "#/"
        }
    }
]

Or Even following

[
        {
            "Condition": {
               
                "KeyPrefixEquals": "root/"
            },
            "Redirect": {
                "HostName": "mydomain.com",
                "ReplaceKeyPrefixWith": "#/"
            }
        }
    ]
  1. After this, /root/foo/baz is redirected to /#/foo/baz, and the page loads at home without error.

I added the following code on-load after my Vue app is mounted. It will take my app to the desired route. You can change router.push part as per Angular or whatever you are using.

if(location.href.includes("#"))
{
  let currentRoute = location.href.split("#")[1];

  router.push({ path: `/root${currentRoute}` })
}

Even if you do not use redirection at the s3 level, having a single base to all routes can be handy in whatever other redirections you may prefer. It helps the backend to identify that is not a request for a real back-end resource, the front-end app will be able to handle it.

Sandeep Dixit
  • 799
  • 7
  • 12
2

If you landed here looking for solution that works with React Router and AWS Amplify Console - you already know that you can't use CloudFront redirection rules directly since Amplify Console does not expose CloudFront Distribution for the app.

Solution, however, is very simple - you just need to add a redirect/rewrite rule in Amplify Console like this:

Amplify Console Rewrite rule for React Router

See the following links for more info (and copy-friendly rule from the screenshot):

Yaro
  • 570
  • 3
  • 20
2

A solution not mentioned here is to use CloudFront Functions to rewrite the request URI to index.html on viewer request:

function handler(event) {
    var request = event.request;
    request.uri = '/index.html';
    return request;
}
mcont
  • 1,749
  • 1
  • 22
  • 33
0

I was looking for an answer to this myself. S3 appears to only support redirects, you can't just rewrite the URL and silently return a different resource. I'm considering using my build script to simply make copies of my index.html in all of the required path locations. Maybe that will work for you too.

Ed Thomas
  • 1,153
  • 1
  • 12
  • 21
  • 2
    Generating index files for each path had crossed my mind as well but it would be difficult to have dynamic paths like http://example.com/groups/5/show. If you see my answer to this question I believe that solves the problem for the most part. It's a bit of a hack but at least it works. – Mark Nutter Jun 01 '13 at 21:04
  • Better to deploy behind an nginx server and return index.html for all incoming urls. I have done this successfully with heroku deployment of ember apps. – moha297 Feb 12 '16 at 03:26
-1

In 2022, the Lambda Function is

function handler(event) {
    var request = event.request;
    var uri = request.uri;
    
    if (uri.endsWith("/")) {
        request.uri += "index.html"
    } else if (!uri.includes(".")){
        request.uri += "/index.html"
    }
    
    return request;
}
yangqiong
  • 1
  • 1
-3

Just to put the extremely simple answer. Just use the hash location strategy for the router if you are hosting on S3.

export const AppRoutingModule: ModuleWithProviders = RouterModule.forRoot(routes, { useHash: true, scrollPositionRestoration: 'enabled' });

Meester Over
  • 161
  • 2
  • 13