The application we build (with ABP commercial) supports a feature where users (employees) posses a set of private pages (private site). The "private sites" are accessed with a handle like an @ sign after or before the domain or website address, like www.website.com/@handle or handle@www.website.com. This might be a general ASP.NET Core issue but ABP might have paved a wonderful approach touching on the middleware. I need some important advice on how to do this, best practices and example code.
Thanks in advance!
ABP Version 10.3 MVC UI Tiered
11 Answer(s)
-
0
- For
www.website.com/@handle(path-based) the cleanest approach in ABP is to treat the handle as a route segment and then (optionally) adjustPathBaseso that link generation works correctly across the UI—exactly the same pattern ABP uses for “tenant in the route”. - For
handle@www.website.com(subdomain / host-based) this is not valid DNS/URL syntax (the@is not a host label separator). If you meanthandle.website.com, that’s host-based routing and should be implemented via subdomains (and optionally ABP’s domain-based tenant resolution). The rest of this answer focuses on the working pattern:/{handle}or/@{handle}.
1) Path-based handle:
/{handle}or/@{handle}Step 1 — Add a middleware that detects
/@handleand moves it intoPathBaseThis preserves ABP’s routing and also makes relative URLs/navigation work (because the app “thinks” it’s hosted under
/@handle).Put this before
app.UseRouting()in your Web/HttpApi.HostOnApplicationInitialization:using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; public override void OnApplicationInitialization(ApplicationInitializationContext context) { var app = context.GetApplicationBuilder(); app.Use(async (httpContext, next) => { // Matches: /@john or /@john/... (handle chars: letters, numbers, underscore, dot, dash) var match = Regex.Match(httpContext.Request.Path, @"^/@([a-zA-Z0-9_.-]+)(?:/.*)?$"); if (match.Success) { var handle = match.Groups[1].Value; // OPTIONAL: validate handle exists (recommended) // var handleStore = httpContext.RequestServices.GetRequiredService<IUserHandleStore>(); // if (await handleStore.ExistsAsync(handle)) { if (httpContext.Request.Path.StartsWithSegments(new PathString("/@" + handle), out var matchedPath, out var remainingPath)) { var originalPath = httpContext.Request.Path; var originalPathBase = httpContext.Request.PathBase; httpContext.Request.Path = remainingPath; httpContext.Request.PathBase = originalPathBase.Add(matchedPath); try { await next(); } finally { httpContext.Request.Path = originalPath; httpContext.Request.PathBase = originalPathBase; } return; } } } await next(); }); app.UseRouting(); // ... }What this gives you:
- Request
/@john/posts/42is internally rewritten to:PathBase = "/@john"Path = "/posts/42"
- Your controllers/pages can keep “normal” routes (
/posts/{id}), while the UI still works under the handle prefix.
Step 2 — Make the handle available to your app code
A simple approach is to read it from
HttpContext.Request.PathBase:public static class HandleHttpContextExtensions { public static string? GetHandleFromPathBase(this HttpContext httpContext) { var pb = httpContext.Request.PathBase.Value; // e.g. "/@john" if (string.IsNullOrWhiteSpace(pb)) return null; return pb.StartsWith("/@") ? pb.Substring(2) : null; } }Then in a controller/page/app service (via
IHttpContextAccessor) you can resolve the “current private site owner” and load data accordingly.Step 3 — Routing endpoints remain standard
You do not need special ABP routing features for this. Keep your normal endpoints, e.g.:
[Route("posts")] public class PostsController : AbpController { [HttpGet("{id}")] public IActionResult Get(int id) => Ok(); }2) ABP-specific “best practice” angle
- The middleware +
PathBasetechnique is proven in ABP for multi-tenancy “tenant in route” scenarios (it avoids broken navigation links and incorrect base paths). - If your “private site” is conceptually similar to multi-tenancy (each handle is an isolated site), you can even model it like a tenant resolution strategy (route/domain contributors). Otherwise, keep it as a custom “handle resolution” abstraction.
3) Sources
- https://abp.io/community/articles/ah7oru97 (shows the same
PathBasemiddleware pattern used for route-based resolution in ABP) - https://docs.abp.io/en/abp/latest/Multi-Tenancy (ABP’s official multi-tenancy resolution concepts; useful if you decide to model handles similarly)
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - For
-
1
Hi,
Good news — for
www.website.com/@handleyou don't need any middleware orPathBaserewriting. This is plain ASP.NET Core routing. The@character is not a reserved routing character (only{}[]are), so you can use it as a literal directly in a route template. ABP doesn't need to add anything on top of this.Pick whichever style matches your UI:
Razor Pages (
Pages/PrivateSite/Index.cshtml):@page "/@{handle}/{**subpath?}" @model PrivateSiteModelMVC controller:
public class PrivateSiteController : AbpController { [HttpGet("@{handle}")] [HttpGet("@{handle}/{**subpath}")] public IActionResult Index(string handle, string? subpath) { // load the private site for `handle` return View(); } }Minimal API:
app.MapGet("/@{handle}/{**subpath}", (string handle, string? subpath) => ...);A few things worth knowing:
{**subpath}(two asterisks) is a catch-all that preserves the/separators.{*subpath}(one asterisk) URL-encodes them to%2F, which you usually don't want for a private-site sub-tree. See Route templates → catch-all.@is just literal text in a route template, so the route/@{handle}/...only matches paths that actually start with/@. Standard routes like/Account/Login,/Identity/..., ABP swagger, etc. are not affected — ASP.NET Core's route precedence considers literal segments more specific than parameter segments. See Route template precedence.handleis fine as a parameter name. Reserved names are onlyaction,area,controller,handler,page— see Reserved routing names.- To resolve the handle from anywhere in your app code, inject
IHttpContextAccessorand readRouteData.Values["handle"], or define a smallICurrentHandleabstraction backed by it — same pattern asICurrentTenant.
About the second form,
handle@www.website.com— that one isn't really usable as a routing scheme. In URI syntax (RFC 3986),userinfo@hostis the userinfo component, and browsers treathttp://handle@www.website.com/as HTTP Basic auth credentials (thehandlepart gets sent in theAuthorization: Basic ...header, not as part of the path or host). It never reaches your routing layer as something you can dispatch on.If what you actually want is a subdomain like
handle.website.com, that's host-based routing — you'd map the wildcard subdomain in DNS and either filter byHostin middleware/endpoint metadata, or model it the same way ABP's domain-based tenant resolver works (see ABPDomainTenantResolveContributor). Let me know if that's the direction you want and I can share an example.References:
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Thanks Maliming very much!! You are helping me a lot. You are clear and to the point, as always. The source links you associate are very helpful too.
A few more points to clarify:
- When Punta, our employee, provides his website address to external systems (like social apps), he just writes as www.website.com/@Punta and our routing system directs the navigator to our PrivateSiteController directly.
- There is an a tag writing area just on our website, with a caption like whom do you want to get? put the a tag here and go. Visitors will put the a tag of the person there and press a button or strike enter, and the routing system gets the visitor to PrivateSiteController directly.
- Ours is the standard model-view-controller style.
Don't you think need for middleware or PathBase rewriting; what do you say about the AI's idea above?
Thanks also in advance!
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
1
Hi,
For all three scenarios you described, plain ASP.NET Core attribute routing is enough — no middleware, no
PathBaserewriting. Below are three working ways to do it (all standard MVC, pick the one that matches your style).Option 1 — Single action, two route templates (simplest)
public class PrivateSiteController : AbpController { [HttpGet("@{handle}")] [HttpGet("@{handle}/{**subpath}")] public async Task<IActionResult> Index(string handle, string? subpath) { var owner = await _privateSiteAppService.FindByHandleAsync(handle); if (owner == null) { return NotFound(); } return View(new PrivateSiteViewModel(owner, subpath)); } }/@Puntahits the first template,/@Punta/posts/42hits the second (withsubpath = "posts/42"). Use{**subpath}(two asterisks) so/characters inside the subpath are preserved instead of URL-encoded — see Route templates → catch-all.Putting two
[HttpGet]attributes on the same action is officially supported, see Multiple attribute routes.Option 2 — Controller-level route prefix + multiple actions
If a private site has fixed sub-pages (e.g.
/@Punta,/@Punta/posts/42,/@Punta/about):[Route("@{handle}")] public class PrivateSiteController : AbpController { [HttpGet] // GET /@Punta public IActionResult Index(string handle) { /* ... */ return View(); } [HttpGet("posts/{id:int}")] // GET /@Punta/posts/42 public IActionResult Post(string handle, int id) { /* ... */ return View(); } [HttpGet("about")] // GET /@Punta/about public IActionResult About(string handle) { /* ... */ return View(); } }Routes on the controller are prepended to routes on each action — see Combining attribute routes. This style plays nicest with link generation: while serving
/@Punta/about, callingUrl.Action("Post", new { id = 42 })(or<a asp-action="Post" asp-route-id="42">) automatically produces/@Punta/posts/42, becausehandle = "Punta"is an ambient route value and the target action's template also has{handle}. You don't have to passhandlemanually inside the controller.Option 3 — Conventional route in
Program.csIf you prefer all routes in one place:
app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "private-site", pattern: "@{handle}/{**subpath}", defaults: new { controller = "PrivateSite", action = "Index" }); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); });Then
PrivateSiteControlleris a plain controller with no[Route]attributes —Index(string handle, string? subpath)works as-is. Reference: MapControllerRoute.Optional — restrict invalid handles at the routing layer
If you want bad handles to never reach the action (and instead get a clean
404), add inline constraints:[HttpGet("@{handle:length(2,32):regex(^[[A-Za-z0-9_.-]]+$)}")] [HttpGet("@{handle:length(2,32):regex(^[[A-Za-z0-9_.-]]+$)}/{**subpath}")]length(2,32)enforces 2–32 characters; the regex restricts to letters/digits/_/./-.[[and]]are how you escape literal[]inside a route template (see Route constraints). Multiple constraints chain with:.Then existence is still validated inside the action against your store.
Scenario 2 — the "enter a handle and go" form
Server-side redirect (clean and lets you put validation in one place):
public class HandleLookupController : AbpController { [HttpPost("/handle/go")] [ValidateAntiForgeryToken] public IActionResult Go(string handle) { if (string.IsNullOrWhiteSpace(handle)) { return RedirectToAction("Index", "Home"); } return Redirect("/@" + Uri.EscapeDataString(handle.Trim())); } }<form method="post" action="/handle/go"> @Html.AntiForgeryToken() <input name="handle" placeholder="Enter a handle" /> <button type="submit">Go</button> </form>About the AI's
PathBasemiddleware ideaIt's a real pattern, but it targets a different problem: when each handle should behave like an isolated sub-site where every page, every static asset, every generated URL is rooted under
/@handle/...(think GitHub Pages-styleusername.github.io/...). For that style you do wantPathBase = "/@handle"so the framework treats the whole app as if it were hosted there.For your case — one controller (or a small group of actions) loading the correct user's data based on the handle — that machinery is overkill and brings real downsides:
- It rewrites
Request.PathandRequest.PathBasefor every matching request, which can interact awkwardly with ABP's built-in middlewares (auth, antiforgery, exception handling) and with non-handle routes you also serve from the same host (/Account/Login,/Identity/..., Swagger, health checks, static files). - Cross-handle navigation (your Scenario 2 — jumping from
/@Punta/...to/@John) is harder, because link generation under aPathBaseof/@Puntawill keep prepending/@Punta/unless you opt out. - It's an extra moving part to maintain, with no benefit over attribute routing for what you described.
So my recommendation is: stay with plain attribute routing (Option 1 or 2). If later you grow into a true sub-site model (per-handle theming, per-handle static asset folders, etc.), you can introduce the
PathBasemiddleware then — it's an additive change, not something you need to commit to upfront.References:
Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - It rewrites
-
0
Hi, Maliming! Success story: I used the Option 2 above: Controller-level route prefix + multiple actions and it worked SUCCESSFULLY.
But One Caveat Once navigated to, all the navigation links on the private site lost the Handle and didn't work, until I worked out around it and added the Handle as a route attribute, as with the following links:
<ul class="navbar-nav flex-grow-1"> <li class="nav-item"> <a class="nav-link text-dark" asp-route-handle="@Model.Handle" asp-action="Index">Home</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-route-handle="@Model.Handle" asp-action="About">About</a> </li> </ul>
Triumph! Thanks again!
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) -
0
Hi,
Great that Option 2 worked! Your
asp-route-handle="@Model.Handle"fix is exactly the right pattern. Here's the why behind it, so the same gotcha doesn't surprise you elsewhere.Why navigation links lost the handle
When you click from
/@Punta/AbouttoHomevia<a asp-action="Index">, ASP.NET Core walks the URL generation algorithm:- Ambient values (current request):
{ controller = PrivateSite, action = About, handle = Punta } - Explicit values (from the tag helper):
{ controller = PrivateSite, action = Index }
The required values for controllers are
controller+action, processed left-to-right. Rule 2.4 in the URL generation reference → Route value invalidation algorithm says:If the ambient value and the explicit value are present, and the two values are different, reject the ambient value and all subsequent ambient values.
So when
action = Index(explicit) ≠action = About(ambient), the algorithm rejects the ambientactionand every ambient value after it — includinghandle. The template@{handle}then has no value, URL generation fails, and the anchor tag helper emits an emptyhref. Same-action self-links work because there's noactionmismatch, so the ambienthandlesurvives.This is by design — the algorithm intentionally avoids mixing route values that belong to a different endpoint. Passing
handleexplicitly viaasp-route-handleis the standard way to opt back in.A small DRY tweak
You can drop the
@Model.Handledependency (so the layout doesn't require every page model to expose aHandleproperty) by reading the route value directly:@{ var handle = (string?)ViewContext.RouteData.Values["handle"]; } <a class="nav-link" asp-route-handle="@handle" asp-action="Index">Home</a> <a class="nav-link" asp-route-handle="@handle" asp-action="About">About</a>The
handlevariable is scoped to the layout file, and views no longer need to plumb it through their models.Same gotcha in controller code
Heads-up: if anywhere in your controllers you call
Url.Action("About")orRedirectToAction("About")to jump between actions ofPrivateSiteController, you'll hit the same invalidation and get an empty URL / a redirect to nowhere. Pass the handle explicitly there too:return RedirectToAction("About", new { handle = RouteData.Values["handle"] });Thanks
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post) - Ambient values (current request):
-
0
Amazing! A good Nurture to my knowledge underneath. All this is useful as my PrivateSite navigation links are prepared dynamically at runtime, and page components may also have jumping links too.
Thanks very much for nurturing everyone reading it!
Markdown supported.Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)