Single Page Applications (S.P.A.) often use a hash as default marker for its routing. Typically you can get this kind of URLs:

  • /#/post/1234
  • /#/user/1234
  • /#/users
  • etc...

This works great but has some pitfalls:

  • Some users don't like it because it "pollutes" the URL
  • Google is now able to index properly SPA (without the need of server side rendering for a lot of cases) but if you use a sitemap or so the # will mark the fragment and therefore will be ignored of the URL representation server side which means all the sample paths of the previous bullet list will all lead to the same endpoint: / or /index.html in general.

To solve that, the routers generally allow you to switch to a routing not relying on the hash. In Angular 4-6  you can switch of LocationStrategy (between the Path and Hash ones) using the toggle useHash:

RouterModule.forRoot(routes, { useHash: false })

Note that this is since several versions the default in Angular so you don't need to set the useHash toggle normally to use the HTML5 navigation (with pushState).

Indeed this change (whatever technology you use) will impact the application at a few levels:

  • In general you will need to ensure you set your application <base/> in your <head/> tag. This is to let the application know how to build relative urls since it can't use the hash anymore as a marker to deduce it. If you migrate from a hash based application to a path based one, this is something you can need to add since it is not mandatory for hash based routing.
  • Your urls will need to be pseudo-absolute in your client: when using the hash routing, all urls are relative to the same endpoint (let say /index.html) but with the path based routing you can be on /user/1 or /admin/user/1 and call the same API (server) endpoint /api/user/1 to grab the user data. In the hash case you will just use api/user/1 but in the path case you will need to ensure to use /api/user/1 if you have a base of /.
  • Finally your backend will need to ensure the client side path redirects to the index.html. There are multiple ways to do it (map all of them with a jsp-file and servlet pattern in your web.xml or ServletContainerInitializer is a simple one) but it is common to put all that logic in a single place and this is the solution we'll detail.

Before digging into the way to redirect to the client side paths to the right resource, let's note that some applications can need some more adjustments if you use the hash based urls in your logic (like adding links in your API payloads).

The solution I generally use to redirect the client side paths to the right resource is a servlet filter. It has the advantage to be a very simple code and keep in a single place (compared to the web.xml solution which requires to duplicate a lot the default servlet mapping). The overall idea is to filter by path the incoming url and redirect on the entry point of your SPA. To get the path you generally just need to drop the webapp context to the request uri and the application entry point is generally the index.xml.

Here is an implementation for the previous urls:

@WebFilter(asyncSupported = true, urlPatterns = "/*")
public class SpaRouterFilter implements Filter {
    @Override
    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException {
        final HttpServletRequest httpServletRequest = HttpServletRequest.class.cast(request);
        final String uri = httpServletRequest.getRequestURI().substring(httpServletRequest.getContextPath().length());
        if (isClientPath(uri)) {
            chain.doFilter(new HttpServletRequestWrapper(httpServletRequest) {
                @Override
                public String getServletPath() {
                    return "/index.html"; // <1>
                }
            }, response);
            return;
        }
        chain.doFilter(request, response);
    }

    private boolean isClientPath(final String uri) { // <2>
        return uri.startsWith("/user")
               uri.startsWith("/post/")
               uri.startsWith("/admin/");
    }
}
  1. overriding the servlet path will be enough to remap current request to the entry point of the javascript application
  2. this method filters by prefix the client urls, a more advanced version could parse a routes.json file to extract them at build time but generally there are not a ton of paths to map so this solution is still not bad like that.

You will have noted that this solution maps the filter on all urls. This is good if you make the isClientPath configurable but if you hardcode as in previous example the paths, you can just be more precise and move the path configuration to the servlet mapping:

@WebFilter(asyncSupported = true, urlPatterns = { "/post/*", "/user/*", "/admin/*" })
public class UrlRewriting implements Filter {
    @Override
    public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException {
        final HttpServletRequest httpServletRequest = HttpServletRequest.class.cast(request);
        final String uri = httpServletRequest.getRequestURI().substring(httpServletRequest.getContextPath().length());
        chain.doFilter(new HttpServletRequestWrapper(httpServletRequest) {
            @Override
            public String getServletPath() {
                return "/index.html";
            }
        }, response);
    }
}

Here we are, with that filter you are able to serve your client router urls transparently and avoid the ugly HTTP 404 you get without :).

 

From the same author:

In the same category: