ASP.NET MVC Domain Routing

Looking for an ASP.NET MVC 6 version? Check this post.

Routing Ever since the release of ASP.NET MVC and its routing engine (System.Web.Routing), Microsoft has been trying to convince us that you have full control over your URL and routing. This is true to a certain extent: as long as it’s related to your application path, everything works out nicely. If you need to take care of data tokens in your (sub)domain, you’re screwed by default.

Earlier this week, Juliën Hanssens did a blog post on his approach to subdomain routing. While this is a good a approach, it has some drawbacks:

  • All routing logic is hard-coded: if you want to add a new possible route, you’ll have to code for it.
  • The VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values) method is not implemented, resulting in “strange” urls when using HtmlHelper ActionLink helpers. Think of http://live.localhost/Home/Index/?liveMode=false where you would have just wanted http://develop.localhost/Home/Index.

Unfortunately, the ASP.NET MVC infrastructure is based around this VirtualPathData class. That’s right: only tokens in the URL’s path are used for routing… Check my entry on the ASP.NET MVC forums on that one.

Now for a solution… Here are some scenarios we would like to support:

  • Scenario 1: Application is multilingual, where www.nl-be.example.com should map to a route like “www.{language}-{culture}.example.com”.
  • Scenario 2: Application is multi-tenant, where www.acmecompany.example.com should map to a route like “www.{clientname}.example.com”.
  • Scenario 3: Application is using subdomains for controller mapping: www.store.example.com maps to "www.{controller}.example.com/{action}...."

Sit back, have a deep breath and prepare for some serious ASP.NET MVC plumbing…

kick it on DotNetKicks.com

Defining routes

Here are some sample route definitions we want to support. An example where we do not want to specify the controller anywhere, as long as we are on home.example.com:

[code:c#]

routes.Add("DomainRoute", new DomainRoute(
    "home.example.com", // Domain with parameters
    "{action}/{id}",    // URL with parameters
    new { controller = "Home", action = "Index", id = "" }  // Parameter defaults
));

[/code]

Another example where we have our controller in the domain name:

[code:c#]

routes.Add("DomainRoute", new DomainRoute(
    "{controller}.example.com",     // Domain with parameters< br />    "{action}/{id}",    // URL with parameters
    new { controller = "Home", action = "Index", id = "" }  // Parameter defaults
));

[/code]

Want the full controller and action in the domain?

[code:c#]

routes.Add("DomainRoute", new DomainRoute(
    "{controller}-{action}.example.com",     // Domain with parameters
    "{id}",    // URL with parameters
    new { controller = "Home", action = "Index", id = "" }  // Parameter defaults
));

[/code]

Here’s the multicultural route:

[code:c#]

routes.Add("DomainRoute", new DomainRoute(
    "{language}.example.com",     // Domain with parameters
    "{controller}/{action}/{id}",    // URL with parameters
    new { language = "en", controller = "Home", action = "Index", id = "" }  // Parameter defaults
));

[/code]

HtmlHelper extension methods

Since we do not want all URLs generated by HtmlHelper ActionLink to be using full URLs, the first thing we’ll add is some new ActionLink helpers, containing a boolean flag whether you want full URLs or not. Using these, you can now add a link to an action as follows:

[code:c#]

<%= Html.ActionLink("About", "About", "Home", true)%>

[/code]

Not too different from what you are used to, no?

Here’s a snippet of code that powers the above line of code:

[code:c#]

public static class LinkExtensions
{
    public static string ActionLink(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, bool requireAbsoluteUrl)
    {
        return htmlHelper.ActionLink(linkText, actionName, controllerName, new RouteValueDictionary(), new RouteValueDictionary(), requireAbsoluteUrl);
    }

    // more of these...

    public static string ActionLink(this HtmlHelper htmlHelper, string linkText, string actionName, string controllerName, RouteValueDictionary routeValues, IDictionary<string, object> htmlAttributes, bool requireAbsoluteUrl)
    {
        if (requireAbsoluteUrl)
        {
            HttpContextBase currentContext = new HttpContextWrapper(HttpContext.Current);
            RouteData routeData = RouteTable.Routes.GetRouteData(currentContext);

            routeData.Values["controller"] = controllerName;
            routeData.Values["action"] = actionName;

            DomainRoute domainRoute = routeData.Route as DomainRoute;
            if (domainRoute != null)
            {
                DomainData domainData = domainRoute.GetDomainData(new RequestContext(currentContext, routeData), routeData.Values);
                return htmlHelper.ActionLink(linkText, actionName, controllerName, domainData.Protocol, domainData.HostName, domainData.Fragment, routeData.Values, null);
            }
        }
        return htmlHelper.ActionLink(linkText, actionName, controllerName, routeValues, htmlAttributes);
    }
}

[/code]

Nothing special in here: a lot of extension methods, and some logic to add the domain name into the generated URL. Yes, this is one of the default ActionLink helpers I’m abusing here, getting some food from my DomainRoute class (see: Dark Magic).

Dark magic

You may have seen the DomainRoute class in my code snippets from time to time. This class is actually what drives the extraction of (sub)domain and adds token support to the domain portion of your incoming URLs.

We will be extending the Route base class, which already gives us some properties and methods we don’t want to implement ourselves. Though there are some we will define ourselves:

[code:c#]

public class DomainRoute : Route

    // ...

    public string Domain { get; set; }

    // ...

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        // Build regex
        domainRegex = CreateRegex(Domain);
        pathRegex = CreateRegex(Url);

        // Request information
        string requestDomain = httpContext.Request.Headers["host"];
        if (!string.IsNullOrEmpty(requestDomain))
        {
            if (requestDomain.IndexOf(":") > 0)
            {
                requestDomain = requestDomain.Substring(0, requestDomain.IndexOf(":"));
            }
        }
        else
        {
            requestDomain = httpContext.Request.Url.Host;
        }
        string requestPath = httpContext.Request.AppRelativeCurrentExecutionFilePath.Substring(2) + httpContext.Request.PathInfo;

        // Match domain and route
        Match domainMatch = domainRegex.Match(requestDomain);
        Match pathMatch = pathRegex.Match(requestPath);

        // Route data
        RouteData data = null;
        if (domainMatch.Success && pathMatch.Success)
        {
            data = new RouteData(this, RouteHandler);

            // Add defaults first
            if (Defaults != null)
            {
                foreach (KeyValuePair<string, object> item in Defaults)
                {
                    data.Values[item.Key] = item.Value;
                }
            }

            // Iterate matching domain groups
            for (int i = 1; i < domainMatch.Groups.Count; i++)
            {
                Group group = domainMatch.Groups[i];
                if (group.Success)
                {
                    string key = domainRegex.GroupNameFromNumber(i);
                    if (!string.IsNullOrEmpty(key) && !char.IsNumber(key, 0))
                    {
                        if (!string.IsNullOrEmpty(group.Value))
                        {
                            data.Values[key] = group.Value;
                        }
                    }
                }
            }

            // Iterate matching path groups
            for (int i = 1; i < pathMatch.Groups.Count; i++)
            {
                Group group = pathMatch.Groups[i];
                if (group.Success)
                {
                    string key = pathRegex.GroupNameFromNumber(i);
                    if (!string.IsNullOrEmpty(key) && !char.IsNumber(key, 0))
                    {
                        if (!string.IsNullOrEmpty(group.Value))
                        {
                            data.Values[key] = group.Value;
                        }
                    }
                }
            }
        }

        return data;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        return base.GetVirtualPath(requestContext, RemoveDomainTokens(values));
    }

    public DomainData GetDomainData(RequestContext requestContext, RouteValueDictionary values)
    {
        // Build hostname
        string hostname = Domain;
        foreach (KeyValuePair<string, object> pair in values)
        {
            hostname = hostname.Replace("{" + pair.Key + "}", pair.Value.ToString());
        }

        // Return domain data
        return new DomainData
        {
            Protocol = "http",
            HostName = hostname,
            Fragment = ""
        };
    }

    // ...
}

[/code]

Wow! That’s a bunch of code! What we are doing here is converting the incoming request URL into tokens we defined in our route, on the domain level and path level. We do this by converting {controller} and things like that into a regex which we then try to match into the route values dictionary. There are some other helper methods in our DomainRoute class, but these are the most important.

Download the full code here: MvcDomainRouting.zip (250.72 kb)

(if you want to try this using the development web server in Visual Studio, make sue to add some fake (sub)domains in your hosts file)

kick it on DotNetKicks.com

This is an imported post. It was imported from my old blog using an automated tool and may contain formatting errors and/or broken images.

Leave a Comment

avatar

44 responses

  1. Avatar for Juli&#235;n
    Juli&#235;n May 20th, 2009

    Excellent work, Maarten! Nice in-depth post.

  2. Avatar for Issa Qandil
    Issa Qandil May 20th, 2009

    Indeed this is a great and a one of a kind post.

    Thanks for sharing.
    I. Qandil

  3. Avatar for Parag Mehta
    Parag Mehta May 20th, 2009

    Nice post! However I hope you understand the SEO implications of subdomains. A SubDomain is technically considered as a different site in Google. Doing subdomain for different languages is not good SEO practice. Because then you will have to take care of each subdomain's Submissions etc.

  4. Avatar for Frobozz
    Frobozz May 22nd, 2009

    Great work!

    But DomainRoute class doesn't allow to use routing for subdomains that contain '-' symbol (for ex. '{section}.site.com').
    The problem is inside DomainRoute.CreateRegex method - it allows only alphanumeric and underscore for '{section}' in this example.
    Don't sure now if it's correct to change it like this
    source = source.Replace("}", @">([a-zA-Z0-9_\-]*))");

  5. Avatar for maartenba
    maartenba May 22nd, 2009

    You can do {sectionX}-{sectionY} if you want. Otherwise, in CreateRegex, apply the change you proposed.

  6. Avatar for Chad
    Chad July 25th, 2009

    My site has membership. I tried implementing what you did here to do subdomain routing based on language.

    Problem is, when logged in, ASP.NET MVC's built-in membership system thinks the site is totally different... asking me to login every time I change subdomain... ;/

  7. Avatar for Chad
    Chad July 25th, 2009

    One more thing... seems {id} must be parsable to int in order to work. Most of my site is that way, but one section uses a string for {id}. Every visit to those urls fails, not caught by the route. Any ideas?

  8. Avatar for Adrian Grigore
    Adrian Grigore July 27th, 2009

    @Chad: I had the same problem. It's not caused by ASP.NET membership though, but by the fact that browser Cookies are only accessible by the domain in which they were created. That's done for security reasons, after all you would not want any site to spy into your logon cookies of other sites.

    The workaround I used is quite simple though: Once a user has supplied the correct user name and password, redirect him to a controller on the domain he wants to log in to. That controller just sets the authentication cookie and redirects again to the page that the user actually wanted to visit.

  9. Avatar for Fred
    Fred October 2nd, 2009

    How can we test it on localhost?
    Am I missing some step or there is a special approach for that?

    Thanks in advance.

  10. Avatar for Maarten
    Maarten October 2nd, 2009

    You can add an entry to your hosts file (c:\windows\system32\drivers\etc\hosts) and map this to your local host. That way, you have multiple domains or subdomains pointing to your localhost.

  11. Avatar for Valer
    Valer November 10th, 2009

    Maarten,

    How about removing some unwanted folders from the URLs when the app is hosted in a subdirectory? Godaddy lets you host multiple websites in the same hosting account (but in different folders) and although I have multiple domain names pointing to the respective folders, when I deploy an MVC app, the full hosting path is there... Ugly and annoying. See my question on Stack Overflow at http://stackoverflow.com/qu...

    Regards,
    Valer

  12. Avatar for Alessandro
    Alessandro January 11th, 2010

    Hello, i tried this solution and it worked well in my localhost, but when i tried using it in my webserver, something went wrong.

    I have the *.caeli.com.br A 75.125.142.234 in my dns records but it seems the mcv cant see it... or any subdomain is redirecting to another place.

    To see whats happening , the website is : www.caeli.com.br. any subdomain you put goes to a default plesk page... any idea whats wrong ?

    Thanks
    Alessandro

  13. Avatar for maartenba
    maartenba January 11th, 2010

    Is your IIS configured to listen on all addresses *.caeli.com.br or just www.caeli.com.br?

  14. Avatar for Alessandro
    Alessandro January 11th, 2010

    I havent done anything in the IIS. I created the website in Plesk (wich is suposed to define all the things i need in IIS ?) And then i added the A record in DNS config inside Plesk pointing all subdomains to the same IP.

    I can ping all the subdomains and they respond to the right IP but thats about it.

    If im missing something in IIS could you tell me what to do ? I know i could create the subdomains in IIS one by one and it should work fine, but the problem is i wanted to do something like www.user1.caeli.com.br and "filter" all the things i want for user 1... and then any new user should have this already set as well...

  15. Avatar for maartenba
    maartenba January 12th, 2010

    Not sure how to do this in Plesk...

  16. Avatar for Alessandro
    Alessandro January 12th, 2010

    Plesk dont prevent me from working directly in IIS too. If i need to change something in IIS i can do it.

    Some friends said i would have to map *.caeli.com.br to some folder in my server too probably in IIS but i dont know how to do that other than creating all subdomains.

  17. Avatar for William
    William February 24th, 2010

    Not fully tested but this seems to work if added to the DomainRoute class:

    [quote]public DomainRoute(string domain, string url, object defaults, object constraints, string[] namespaces)
    : base(url, new RouteValueDictionary(defaults), new MvcRouteHandler())
    {
    if (url == null) {
    throw new ArgumentNullException("url");
    }

    Route route = new Route(url, new MvcRouteHandler()) {
    Defaults = new RouteValueDictionary(defaults),
    Constraints = new RouteValueDictionary(constraints)
    };

    if ((namespaces != null) && (namespaces.Length > 0)) {
    route.DataTokens = new RouteValueDictionary();
    route.DataTokens["Namespaces"] = namespaces;
    }

    Domain = domain;
    }[/quote]

  18. Avatar for Daniel Steigerwald
    Daniel Steigerwald June 27th, 2010

    Nice work! You should consider put it to github or codeplex.

  19. Avatar for Gorkem Pacaci
    Gorkem Pacaci June 29th, 2010

    There is a little problem with this solution. Say, you want to handle subdomains as different users:

    routes.Add("SD", new DomainRoute("user}.localhost", "", new { controller = "Home", action = "IndexForUser", user="u1" } ));

    It caches the homepage as well. This is because of the regex that's generated. In order to fix this, you can make a copy of the CreateRegex method in DomainRoute.cs, name it CreateDomainRegex, change the * on this line to +:

    source = source.Replace("}", @">([a-zA-Z0-9_]*))");

    and use this new method instead to generate domain regex in GetRouteData method:

    domainRegex = CreateDomainRegex(Domain);

    Then it won't catch the non-sub-domain homepage. (http://localhost/)

  20. Avatar for Tristan
    Tristan July 2nd, 2010

    Has anyone managed to get subdomain level routing working against areas within MVC.

    So:
    http://{area}.domain.com/{controller}/{index}

    Then you could have a Blog area, and a Shop area all with different controllers etc...

    This seems to me to be the perfect use of this extended functionality.

  21. Avatar for Ken
    Ken August 28th, 2010

    HI there,

    Has anyone done a version of this for Webforms 4.0?

    Regards

  22. Avatar for maartenba
    maartenba August 30th, 2010

    This version should work with webforms as well.

  23. Avatar for Ken
    Ken August 30th, 2010

    Hi there,

    Thanks for replying. I have a solution working now. I did the domain switching in a custom handler. It was mainly hard-coded, but that was ok for the project in question.

    Regards!

  24. Avatar for Felipe Lima
    Felipe Lima August 30th, 2010

    Thanks a lot, man! This code fit perfectly my needs!! You rock :)

  25. Avatar for Hernan
    Hernan September 1st, 2010

    This implementation is not working with catch all routing. For instance,

    routes.Add("DomainRoute", new DomainRoute(
    "{language}.localhost", // Domain with parameters
    "{*view}", // URL with parameters
    new { language = "es", controller = "Main", action = "Index", view = UrlParameter.Optional } // Parameter defaults
    ));

    The parsing of "{*view}" fails because of the asterisk. Can you please help me with this? Thanks.

  26. Avatar for Brad
    Brad December 11th, 2010

    Same question as Tristan... has anyone been able to get this to work using MVC 2/3 Areas functionality?

  27. Avatar for vinay sharma
    vinay sharma December 31st, 2010

    plz provide me same code for MVC2 Architeture ..........

  28. Avatar for rczjp
    rczjp January 27th, 2011

    Thanks
    I have some errors “System.Web.Mvc.MvcHtmlString” can not convert to “string"
    How to resolve?

    return htmlHelper.ActionLink(linkText, actionName, controllerName, domainData.Protocol, domainData.HostName, domainData.Fragment, routeData.Values, null);

  29. Avatar for jse
    jse April 13th, 2011

    Hi,
    if used in MVC 3 (maybe MVC 2 as well) all ActionLink extensions return MvcHtmlString, so change return types in LinkExtensions.cs to MvcHtmlString

  30. Avatar for jse
    jse April 13th, 2011

    Had the same issue.
    I've changed GetRouteData to get the path values from base and not by parsing them in the overridden method.
    Didn't test thoroughly, but works on my machine for the time being
    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
    // Build regex
    _DomainRegex = CreateRegex(Domain);

    // Request information
    string requestDomain = httpContext.Request.Headers["host"];
    if (!string.IsNullOrEmpty(requestDomain))
    {
    if (requestDomain.IndexOf(":") > 0)
    {
    requestDomain = requestDomain.Substring(0, requestDomain.IndexOf(":"));
    }
    }
    else
    {
    if (httpContext.Request.Url != null) requestDomain = httpContext.Request.Url.Host;
    }

    // Match domain and route
    Match domainMatch = _DomainRegex.Match(requestDomain ?? "");

    // Route data
    RouteData data = base.GetRouteData(httpContext);
    if (domainMatch.Success)
    {
    data = data ?? new RouteData(this, RouteHandler);

    // Add defaults first
    if (Defaults != null)
    {
    foreach (KeyValuePair<string, object> item in
    Defaults.Where(item => ! data.Values.ContainsKey(item.Key)))
    {
    data.Values[item.Key] = item.Value;
    }
    }

    // Iterate matching domain groups
    for (int i = 1; i < domainMatch.Groups.Count; i++)
    {
    Group group = domainMatch.Groups[i];
    if (group.Success)
    {
    string key = _DomainRegex.GroupNameFromNumber(i);

    if (!string.IsNullOrEmpty(key) && !char.IsNumber(key, 0))
    {
    if (!string.IsNullOrEmpty(group.Value))
    {
    data.Values[key] = group.Value;
    }
    }
    }
    }
    }

    return data;
    }

  31. Avatar for Braian87b
    Braian87b June 30th, 2013

    I had the same problem, couldn't fixed yet, how you workaround that problem?

  32. Avatar for hbopuri
    hbopuri August 5th, 2013

    Thank you very much this was very helpful for me. Only thing you may want to check on this, I had to make some changes here
    //htmlHelper.ActionLink(linkText, actionName, controllerName, domainData.Protocol, domainData.HostName, domainData.Fragment, routeData.Values, null);

    return htmlHelper.ActionLink(

    linkText, actionName, controllerName,

    new RouteValueDictionary(new {domainData.Protocol, domainData.HostName, domainData.Fragment}),

    routeData.Values, false);

  33. Avatar for ThetaSolutions
    ThetaSolutions May 4th, 2014

    You have posted this solution in 2009, Is there any simple way now in 2014 to do the same in asp.net MVC5 or MVC4? i mean sub domains routing to controllers?

  34. Avatar for IDisposable
    IDisposable November 19th, 2014

    Love IT. I've got an enhanced variant here https://gist.github.com/IDi...

    Made it allow hyphens in the domain segment match.

    Made it require the domain segment pattern to match one or more characters per comment https://stackoverflow.com/q...

    Made the domain patterns use a ConcurrentDictionary of Regex patterns, since in most uses you will have multiple routes with the same pattern.

    Made RemoveDomainTokens use the list of tokens it inserted (cached away in the Items collection) to remove from the value returned in GetRouteData instead of using another Regex to parse it back out.

    Ensures that once a route is matched we don't try others that might match when GetRouteData is called.

    Added helpers to let you do the routing more idiot proof using DomainRouteCollection andDomainAreaRegistrationContext adapters

  35. Avatar for Sonu kumar
    Sonu kumar November 21st, 2014

    Its working fine for multiple sub-domain. But i also want solution for this. {store1.abc.com/about-us}
    {store2.abc.com/about-us}, {store3.abc.com/about-us} and many more. And all about us page is different for different store. But it is only accessing the same about-us for all store.

    Can any one please help me? My application is in asp.net mvc.

    Thanks in advance.

  36. Avatar for Cassandra
    Cassandra March 13th, 2015

    Hi, thanks for sharing this, great explanation. I have tried to apply this solution, and it works fine, except when I create ActionLinks. If I create them this way "@Html.ActionLink("Test", "TestIt", "Home", null, null, true)", the link is generated as "/TestIt", it doesn't add the controller name, but it is returned ok in the ActionLink extension (in "return htmlHelper.ActionLink(linkText, actionName, controllerName, routeValues, htmlAttributes);"). Am I missing something?

  37. Avatar for Bond
    Bond March 18th, 2015

    I could not open that MvcDomainRouting.zip project, I could not find System.Web.Mvc in .Net Framework 3.5, I have VS 2013 on my machine.

  38. Avatar for gaurav
    gaurav October 7th, 2015

    Pl. share full source code of Multi tenancy URL routing in mvc5. I did lot of effort, But i didn't get, what i need.

    eg.
    my application URl is www.example.com. if a & b is tenant.

    As i have need, domain URl become like www.a.example.com &www.b.example.com

    Please share me full source code in MVC-5

  39. Avatar for gaurav
    gaurav October 8th, 2015

    Dear Sonu,
    have you tested for multiple sub-domain, if you had done it. Please share me source code for mvc5.

  40. Avatar for Daniel Edström
    Daniel Edström August 25th, 2016

    I can't download the source code, can you make it available again please?

    That would be awesome, thanks!

  41. Avatar for Sandeep D
    Sandeep D November 22nd, 2017

    Please share me source code for mvc5 subdomain will pass in query string parameter
    when particular action hit and dynamic will generate
    Ex: www.alabama.example.com
    www.newyork.example.com

  42. Avatar for Hafix Mohsin
    Hafix Mohsin February 12th, 2018

    I have implemented all of this code but my application is not accessible. i.e made classes as well as routes but not accessible at local machine as well as on server.

  43. Avatar for ivin raj
    ivin raj March 19th, 2018

    sir can you update new version mvc code?

  44. Avatar for Martin Kirk
    Martin Kirk May 28th, 2018

    how about mapping a domain to an area ??... would that make sense ?

    eg:
    www.Area1.com -> www.hostapplication.com/Area1/
    www.Area2.com -> www.hostapplication.com/Area2/

    This would make it possible to share common code between multiple websites and retain a lot of flexibility in having multiple controllers / actions / views per Area..

    I've been thinking / trying to make this work, but some parts of MVC starts breaking. Especially the ActionLink whoose VirtualPathReducer throws an exception.