Redirecting Routes To Maintain Persistent URLs

Over a decade ago, Tim Berners-Lee, creator of the World Wide Web instructed the world know that cool URIs don’t change with what appears to be a poem, but it doesn’t rhyme and it’s not haiku.

What makes a cool URI?
A cool URI is one which does not change.
What sorts of URI change?
URIs don't change: people change them.

In a related article, URL as UI, usability expert Jakob Nielsen lists the following criteria for a usable site:

  • a domain name that is easy to remember and easy to spell
  • short URLs
  • easy-to-type URLs
  • URLs that visualize the site structure
  • URLs that are "hackable" to allow users to move to higher levels of the information architecture by hacking off the end of the URL
  • persistent URLs that don't change

The permanence of URLs is a fundamental trait of the web that seems to run counter to one of the benefits of using a feature like ASP.NET Routing. For example, one benefit of routing is you can change a route from {controller}/{action}/{id} to {controller}/{id}/{action} and have every URL in your site corresponding to that route automatically be updated.

This is very nice during development when you’re still fleshing out your URLs and haven’t committed to anything, but once you’ve published your site, changing a route URL violates the sacred trait of URL permanence.

This is exactly where I find myself with Subtext. All of our existing URLs end with the .aspx extension, a practice which Jon Udell convincingly argued is harmful. In the upcoming version of Subtext, we’re moving to extensionless URLs by building upon the great support built into ASP.NET 4 and Routing.

I could simply change our routes to remove the .aspx extension, but that would break nearly every existing URL in every blog running on Subtext. So much for URL permanence, right?

There’s a Better Way

Rather than changing routes, what I really want is a way to simply redirect the existing route to a new route. This is pretty easy, but there are a few caveats to keep in mind that make it non-trivial.

  1. Since you don’t want to generate URLs for the old route, the legacy route should never be selected for URL generation. It’s only for matching incoming requests.
  2. The legacy route should be registered after the new URL to ensure it doesn’t accidentally match and supersede the new URL.

I wrote a library that provides a RedirectRoute and a simple extension method for registering a RedirectRoute that satisfies these conditions. Let’s look at an example of how it would be used.

Let’s suppose we have the following route defined and the site has been published to the web..

routes.MapRoute("old", "foo/{controller}/{action}/{id}");

But later, we decide we want all such URLs to start with /bar instead and we want to re-order the id and action segments of the URL.

Here’s an example of how we can do that using this new library.

var route = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
routes.Redirect(r => r.MapRoute("old", "foo/{controller}/{action}/{id}"))
.To(route);

This snippet registers the new route and passes that route to the RedirectRoute that was returned by a call to the Redirect extension method. The RedirectRoute delegates to the old route to match incoming requests. With this in place, every request matching the old route will be redirected to the new route.

Thus a request for /foo/home/index/123 will be redirected to /bar/home/123/index.

Why The Lambda Expression?

To fully understand what’s going on under the hood, I need to explain why the API takes in a lambda expression rather than simply taking in two routes, old route and new route.

Let’s suppose that the API did just that, simply accepted two routes. Here’s what a naïve attempt to use the method might look like.

var new = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
var old = routes.MapRoute("new", "foo/{controller}/{action}/{id}");
routes.Redirect(old).To(new);

Hopefully it’s immediately apparent why this is not good. The old route is mapped before the redirect route. So the redirect route will never be matched. 

The MapRoute extension method not only creates a route, but it adds it to the route collection. So we could have manually created the route, but that’s a pain if you’re already using the MapRoute method to create the route. Or, we could have done this:

var new = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
var throwAway = new RouteCollection();
var old = throwAway.MapRoute("new", "foo/{controller}/{action}/{id}");
routes.Redirect(old).To(new);

Requiring the user of the API to create a throwaway route collection is ugly when the API itself could do it for you. Hence the lambda expression argument to Redirect. Internally, the method creates a throwaway route collection and calls the expression against that instead of against the main route collection.

Implementation Details

I won’t post the full source here, but the implementation details are pretty simple. Here’s the implementation of GetRouteData which is the method called when matching incoming requests.

public override RouteData GetRouteData(HttpContextBase httpContext) {
    // Use the original route to match
    var routeData = SourceRoute.GetRouteData(httpContext);
    if (routeData == null) {
        return null;
    }
    // But swap its route handler with our own
    routeData.RouteHandler = this;
    return routeData;
}

Notice that I use the source route, which is the old route passed into the redirect route, to match the request, but I swap the route handler with the redirect route. RedirectRoute also implements IRouteHandler. It was a little implementation shortcut I took which happens to work fine in this case.

The implementation of GetVirtualPath is even simpler.

public override VirtualPathData GetVirtualPath(RequestContext requestContext
, RouteValueDictionary values) { // Redirect routes never generate an URL. return null; }

We never want to generate a URL to the old route, so this method always returns null.

As mentioned, RedirectRoute implements IRouteHandler, so we should look at its implementation.

public IHttpHandler GetHttpHandler(RequestContext requestContext) {
  var requestRouteValues = requestContext.RouteData.Values;

  var routeValues = AdditionalRouteValues.Merge(requestRouteValues);

  var vpd = TargetRoute.GetVirtualPath(requestContext, routeValues);
  string targetUrl = null;
  if (vpd != null) {
    targetUrl = "~/" + vpd.VirtualPath;
    return new RedirectHttpHandler(targetUrl, Permanent, isReusable: false);
  }
  return new DelegateHttpHandler(
httpContext => httpContext.Response.StatusCode = 404, false); }

Notice that we make use of the DelegateHttpHandler which is something I wrote about a while ago.

Where to get it?

All the code I showed here is now part of the RouteMagic library I blogged about recently. I’ve updated the package so all you need to do is Install-Package RouteMagic within NuGet.

What others have said

Requesting Gravatar... Richard McCutchen Feb 02, 2011 3:06 PM
# re: Redirecting Routes To Maintain Persistent URLs
Very nice indeed Phil. Just wanted to let you know that since I took the leap-of-faith dive into ASP.NET MVC I have learned so much from your blog, and this one on redirecting to maintain persistent route is, well in my opinion it's sheer genius.

I'll definitely be downloading the source for RouteMagic. I suppose RouteMagic is compiled to work with MVC 3 isn't it? If so would you mind if I made some changes to make it work with MVC 2. I'm kind of stuck working primarily with MVC 2 (Client didn't want his application built with technologies that were in 'beta', no matter who the author/company is).

Kudos to you and all you give back to the .NET world, it's people like you who will ensure the .NET Community stays fresh, up-to-date and ever growing, in the right routes.
Requesting Gravatar... Richard McCutchen Feb 02, 2011 3:17 PM
# re: Redirecting Routes To Maintain Persistent URLs
Sorry Phil, one more question please. Since there are many of us who are in the early stages of getting a firm grasp on MVC, would it be possible for you to write a series on MVC 2, something like from beginning to intermediate then intermediate to advanced.

Covering such topics and data binding (and complex binding), ViewModels versus Models, IoC utilizing StructureMap (that's the IoC container I prefer actually). And maybe topics on using the generic repository pattern coupled with the unit of work pattern (or other n-tier styles, even something like the Specification pattern (I have implementation of all 3 but would like to see your views on them)

I know the writing you do takes a lot of work, and I could completely understand if you wanted to decline, especially with MVC 3 and Razor Engine being so popular so fast. So no hard feelings (none and I mean it)if it's nothing something that interests you (or you just have so much on your plate and aren't sure you could finish what you started. But remember one thing, I'd be more than willing to help out and I'm sure I could fine at least 1 or 2 more to help as well.

Let me know richard(dot)mccutchen(at)psychocoder(dot) net. I look forward to hearing from you ;)

Thanks,
Richard :)
http://blog.psychocoder.net
Requesting Gravatar... Pierrick Feb 02, 2011 3:24 PM
# re: Redirecting Routes To Maintain Persistent URLs
Great post Phil! This could become really handy when updating web apps :)
One thing though on the following
var new = routes.MapRoute("new", "bar/{controller}/{id}/{action}");
var old = routes.MapRoute("new", "foo/{controller}/{action}/{id}");
routes.Redirect(old).To(new);

Did you mean in the 2nd route?
routes.MapRoute("old", "foo/{controller}/{action}/{id}");

Thanks!
Requesting Gravatar... Maarten Balliauw Feb 02, 2011 4:12 PM
# re: Redirecting Routes To Maintain Persistent URLs
Great feature, Phil! Any chance to get my localized route and domain route feature in (the pull request is waiting for you at CodePlex).
Requesting Gravatar... Colin Farr Feb 02, 2011 5:05 PM
# re: Redirecting Routes To Maintain Persistent URLs
@Pierrick, you are worrying about the wrong example. The lambda expression is the one that you want. The one you have was the example that wont work.

Also, no he didn't mean the 2nd route to be what you suggested. It was an example of a changing URL.
Requesting Gravatar... Pierrick Feb 02, 2011 5:29 PM
# re: Redirecting Routes To Maintain Persistent URLs
@Colin Farr I see thanks
Requesting Gravatar... Russ Feb 02, 2011 10:13 PM
# re: Redirecting Routes To Maintain Persistent URLs
Hmmmm... shouldn't you be 301'ing these instead of redirecting?
Requesting Gravatar... Jon Udell Feb 02, 2011 10:25 PM
# re: Redirecting Routes To Maintain Persistent URLs
Very nice, Phil. This will certainly help the cause. Thanks!
Requesting Gravatar... Andrey Shchekin Feb 02, 2011 10:46 PM
# re: Redirecting Routes To Maintain Persistent URLs
Great stuff that leaves me even more confused about http://nuget.org/Packages/Packages/Details/{whatever} URLs that Jakob Nielsen would probably dislike with passion.
Requesting Gravatar... Paul Feb 04, 2011 2:29 AM
# re: Redirecting Routes To Maintain Persistent URLs
Great article! I am 100% with Russ though, you really should be sending a 301 not a 404. This tell a search engine that the content from the old page is still pertinet/valid, just moved. A 404 is going to tell the search engine that the page is gone and that you want it to redirect to your NEW page. That is bad because NEW pages without 301's are just that, new and have no credit in the eyes of search engines. Just my opinion, but def going to use this code in a project I am working on.
Requesting Gravatar... haacked Feb 04, 2011 6:29 AM
# re: Redirecting Routes To Maintain Persistent URLs
@Paul @Russ I think you misinterpreted the implementation. Notice that if the target route matches, it returns a RedirectHttpHandler which redirects to the target route. If it doesn't match, then we return a 404.

I think that's exactly what you want. After all, if the target page really isn't there, it should be a 404. If it is there, then you get redirected with the appropriate redirect status code.
Requesting Gravatar... haacked Feb 04, 2011 6:30 AM
# re: Redirecting Routes To Maintain Persistent URLs
@andrey I agree and I hope to get that improved in the future. :)
Requesting Gravatar... haacked Feb 04, 2011 6:34 AM
# re: Redirecting Routes To Maintain Persistent URLs
@Richard All the writing on ASP.NET MVC 2 I want to do is in the book Professional ASP.NET MVC 2 or on my blog already :) Also check out Brad Wilson's blog.

I've also heard that Steve Sanderson's book is really good too. He's now on the ASP.NET team as well.
Requesting Gravatar... Andy West Feb 05, 2011 5:48 AM
# re: Redirecting Routes To Maintain Persistent URLs

What makes a cool URI?
A cool URI is one which does not change.
What sorts of URI change?
URIs don't change: people change them.


This implies that all URIs are cool. I'm not sure I agree with that. ;)
Requesting Gravatar... Stuart Feb 06, 2011 10:23 PM
# re: Redirecting Routes To Maintain Persistent URLs
Hi Phil

Firstly - thanks for the blog. I get a lot of great samples and ideas here.

I'm actually struggling with what I believe should be a simple routing scenario... the details are over on the .net forums:

forums.asp.net/p/1650496/4291223.aspx#4291223

If you have a little time, take a look as I'd be delighted to hear how the development team intend for such scenarios to be handled!

All the best, Stuart
Requesting Gravatar... Solmead Feb 07, 2011 12:46 PM
# re: Redirecting Routes To Maintain Persistent URLs
What if I have a url:
/ImNew/ContactUs

And it uses the default route. "{controller}/{acton}/{id}"

I want to redirect /ImNew/ContactUs to /ImNew/Locations
But if I put the redirect after the default rule the redirect never gets hit.
Is it possible to do this?
Requesting Gravatar... haacked Feb 09, 2011 3:57 PM
# re: Redirecting Routes To Maintain Persistent URLs
@Solmead I would create a specific route for /lmnew/contactus to redirect to the new url and put it *before* the default route.
Requesting Gravatar... Claire Feb 15, 2011 5:59 AM
# re: Redirecting Routes To Maintain Persistent URLs
This is a great solution, but I really wish ASP.NET had a version of apache mod_rewrite--it's so much easier!
Requesting Gravatar... haacked Feb 15, 2011 4:03 PM
# re: Redirecting Routes To Maintain Persistent URLs
@Claire, we do! Check out the IIS Url Rewriter Module learn.iis.net/.../using-the-url-rewrite-module/
Requesting Gravatar... Rex Apr 27, 2011 5:05 AM
# re: Redirecting Routes To Maintain Persistent URLs

I'm sure I'm misunderstanding something fundamental here, but I'm moving an MVC3 app from IIS6 to IIS7 and want to redirect all .mvc routes to their extension-free counterparts.

I downloaded the RouteMagic source and in the web demo project placed only the new route and a redirect from the old route in RegisterRoutes:


var defaultRoute = routes.MapRoute(
"Default",
"{controller}/{action}",
new { controller = "Home", action = "Index" }
).SetRouteName("Default");

routes.Redirect(r => r.Map("OldDefault",
"{controller}.mvc/{action}",
new { controller = "Home", action = "Index" }))
.To(defaultRoute);


When I fire up the example with RouteDebugger attached the url /home/index works as expected, but for /home.mvc/index RouteDebugger shows it matching the "{controller}/{action}" route, not "{controller}.mvc/{action}" and the value for the controller token is "home.mvc" instead of just "home", which doesn't map to an existing controller (there's a HomeController.cs, not Home.MvcController.cs).

I'm thinking I'm trying to do something that's not possible here but I can't put my finger on it. Sorry for the long post...
Requesting Gravatar... haacked Apr 27, 2011 8:40 AM
# re: Redirecting Routes To Maintain Persistent URLs
Routing matches routes in order. {controller}/{action} will match any 2 segment route. Well, /home.mvc/index is a 2 segment URL, so it is going to match.

The fix should be as simple as re-ordering the two routes and putting the {controller}.mvc/{action} route first.
Requesting Gravatar... Rex Apr 27, 2011 10:46 AM
# re: Redirecting Routes To Maintain Persistent URLs
Thanks Phil. I'm pretty sure I understand. Putting the {controller}.mvc/{action} route first works, but that redirects home/index to home.mvc/index and I was hoping for a way to do the inverse. That is for all the static links out in the wild (e.g. users' bookmarks, old reports, links in e-mail) that point to home.mvc/index, have them redirect to home/index. It sounds like since any two-segment url will match {controller}/{action} it won't work using redirect. No biggie... we'll just have to live with the .mvc extension for a while longer.

Thanks for RouteDebugger, RouteMagic, all your work on MVC and NuGet, and other contributions to the Web Stack of Love, and of course your blog. They help to make work fun.
Requesting Gravatar... haacked Apr 28, 2011 9:26 AM
# re: Redirecting Routes To Maintain Persistent URLs
@Rex Ah! My mistake. Here's another easy approach. Add a constraint to the default route to not allow matching the dot character.

var defaultRoute = routes.MapRoute(
"Default",
"{controller}/{action}",
new { controller = "Home", action = "Index" },
new {controller = "[^\.]+"}
).SetRouteName("Default");

That way it won't match anything with a . character.
Requesting Gravatar... Kenneth Jan 27, 2012 11:45 AM
# re: Redirecting Routes To Maintain Persistent URLs
I'm having some trouble using the redirect route. I keep getting the error "A potentially dangerous Request.Path value was detected from the client (:)." and the redirect doesn't work :(

I think I'm doing everything correctly, but is the above error something that's easy to fix?
Requesting Gravatar... Paul Apr 26, 2012 6:05 PM
# re: Redirecting Routes To Maintain Persistent URLs
Hey Phil - i cant get the 301 redirect to work - i simply want to redirect to the same controller but with a lower case starting letter ie from "MyControllerName/" to "myControllerName" so that the url in the user's browser changes - can you advise a simple example? Thanks
Requesting Gravatar... haacked Apr 26, 2012 10:57 PM
# re: Redirecting Routes To Maintain Persistent URLs
You'll need to write a custom route. Check out my RouteMagic library for some guidance.

What do you have to say?

(will show your gravatar)
Please add 5 and 2 and type the answer here: