Tutorial 4: Routing
June 1, 2026 · View on GitHub
Learn how to handle client-side navigation with URLs, route parsing, and the browser history API.
Prerequisites: Tutorial 3: API Integration
Time: 25 minutes
What you'll learn:
- How Abies handles navigation as regular messages
- Parsing URLs into application routes
- Programmatic navigation with navigation commands
- The
UrlChangedandUrlRequestmessage types - Using
Navigation.UrlChangesas a subscription
Navigation in Abies
Unlike frameworks where routing is a separate subsystem, Abies treats navigation as regular messages flowing through the same MVU loop. When the URL changes:
- The runtime dispatches a
UrlChanged(Url)message - Your
Transitionfunction handles it like any other message - You return a new model (e.g., switch to a different page)
This means routing is just pattern matching — no router configuration, no route tables, no middleware.
The URL Type
Abies represents URLs with a structured Url record:
public record Url(
IReadOnlyList<string> Path, // ["article", "hello-world"]
IReadOnlyDictionary<string, string> Query, // { "page": "2" }
Option<string> Fragment); // Some("comments") or None
The Path is already split into segments — no string parsing needed. You pattern-match directly on the segments:
url.Path switch
{
[] or [""] => /* home page */,
["about"] => /* about page */,
["users", var id] => /* user profile with captured id */,
_ => /* 404 */
};
Building a Multi-Page App
Let's build a simple app with Home, About, and User Profile pages.
Model
using Picea.Abies.DOM;
using Picea.Abies.Subscriptions;
using Picea;
using static Picea.Abies.Html.Attributes;
using static Picea.Abies.Html.Elements;
using static Picea.Abies.Html.Events;
namespace RoutingApp;
/// <summary>Discriminated union for pages.</summary>
public abstract record Page
{
private Page() { }
public sealed record Home : Page;
public sealed record About : Page;
public sealed record UserProfile(string Username, bool IsLoading) : Page;
public sealed record NotFound : Page;
}
public record Model(Page CurrentPage);
The Page type is a discriminated union (sealed hierarchy). Each variant holds the data specific to that page.
Messages
public interface AppMessage : Message;
/// <summary>User profile data loaded from the API.</summary>
public record UserLoaded(string Username, string Bio) : AppMessage;
/// <summary>API request failed.</summary>
public record LoadFailed(string Error) : AppMessage;
Notice what's not here: there's no Navigate message. Navigation is handled by UrlChanged and UrlRequest, which are built-in framework message types.
Commands
/// <summary>Fetch a user's profile from the API.</summary>
public record FetchUser(string Username) : Command;
Route Parsing
Create a pure function that converts a URL into a page with optional commands:
public static class Route
{
public static (Page Page, Command Command) FromUrl(Url url) =>
url.Path switch
{
[] or [""] => (new Page.Home(), Commands.None),
["about"] => (new Page.About(), Commands.None),
["users", var username] =>
(new Page.UserProfile(username, IsLoading: true),
new FetchUser(username)),
_ => (new Page.NotFound(), Commands.None)
};
}
Key insight: Route parsing is a pure function. It takes a URL and returns a page + commands. No side effects, no state mutation. When a route needs data (like a user profile), it returns both the loading-state page and the fetch command.
Transition
public sealed class App : Program<Model, Unit>
{
public static (Model, Command) Initialize(Unit _)
{
// Start with home page. The runtime will dispatch UrlChanged
// with the actual browser URL as the first message.
return (new Model(new Page.Home()), Commands.None);
}
public static (Model, Command) Transition(Model model, Message message) =>
message switch
{
// Browser URL changed (back/forward, initial load, link click)
UrlChanged url => HandleUrlChanged(url.Url),
// API response
UserLoaded msg when model.CurrentPage is Page.UserProfile profile =>
(model with
{
CurrentPage = new Page.UserProfile(
profile.Username, IsLoading: false)
}, Commands.None),
LoadFailed when model.CurrentPage is Page.UserProfile =>
(model with { CurrentPage = new Page.NotFound() },
Commands.None),
_ => (model, Commands.None)
};
private static (Model, Command) HandleUrlChanged(Url url)
{
var (page, command) = Route.FromUrl(url);
return (new Model(page), command);
}
How it works:
- The runtime dispatches
UrlChanged(url)whenever the browser URL changes Transitiondelegates toHandleUrlChanged, which calls the pureRoute.FromUrl- The returned page becomes the new model; any commands trigger data loading
View with Navigation Links
public static Document View(Model model)
{
var title = model.CurrentPage switch
{
Page.Home => "Home",
Page.About => "About",
Page.UserProfile p => $"{p.Username}'s Profile",
_ => "Not Found"
};
return new(title,
div([],
[
Nav(),
Content(model.CurrentPage)
]));
}
static Node Nav() =>
nav([class_("navbar")],
[
a([href("/")], [text("Home")]),
a([href("/about")], [text("About")]),
a([href("/users/alice")], [text("Alice's Profile")])
]);
static Node Content(Page page) =>
page switch
{
Page.Home => div([], [h1([], [text("Welcome Home")])]),
Page.About => div([], [h1([], [text("About Us")])]),
Page.UserProfile { IsLoading: true } =>
div([], [text("Loading profile...")]),
Page.UserProfile p =>
div([], [h1([], [text($"{p.Username}'s Profile")])]),
Page.NotFound => div([], [h1([], [text("404 — Not Found")])]),
_ => text("")
};
How links work: Regular <a href="..."> links are intercepted by the runtime. Instead of triggering a full page reload, the runtime:
- Prevents the default browser navigation
- Updates the browser URL via the History API
- Dispatches
UrlChanged(newUrl)into yourTransitionfunction
You don't need to use special link components — regular a elements with href just work.
Subscriptions
To receive URL change notifications, subscribe to Navigation.UrlChanges:
public static Subscription Subscriptions(Model model) =>
Navigation.UrlChanges(url => new UrlChanged(url));
public static Result<Message[], Message> Decide(Model state, Message command) =>
Result<Message[], Message>.Ok([command]);
public static bool IsTerminal(Model state) => false;
}
Navigation.UrlChanges listens for browser popstate events (back/forward navigation) and intercepted link clicks, dispatching them as UrlChanged messages.
Programmatic Navigation
Sometimes you need to navigate in response to an action (e.g., redirect after login). Use navigation commands:
// In Transition:
LoginSucceeded =>
(model with { CurrentUser = user },
Navigation.PushUrl(new Url(["dashboard"],
new Dictionary<string, string>(), Option<string>.None)))
Available navigation commands:
| Command | Effect |
|---|---|
Navigation.PushUrl(url) | Navigate to URL, add to history |
Navigation.ReplaceUrl(url) | Navigate to URL, replace current history entry |
Navigation.Back | Go back one entry |
Navigation.Forward | Go forward one entry |
Navigation.ExternalUrl(href) | Navigate to an external URL (full page load) |
Push vs. Replace
- PushUrl: Adds a new entry to the browser history. The user can press Back to return.
- ReplaceUrl: Replaces the current history entry. Useful for redirects where you don't want the user to "go back" to the redirect page.
// After login: redirect to dashboard (replace login page in history)
LoginSucceeded =>
(model with { Session = session },
Navigation.ReplaceUrl(dashboardUrl))
// After creating an article: navigate to the new article (push to history)
ArticleCreated slug =>
(model,
Navigation.PushUrl(new Url(["article", slug],
new Dictionary<string, string>(), Option<string>.None)))
External Links
For links to external sites, use Navigation.ExternalUrl:
OpenDocs =>
(model, Navigation.ExternalUrl("https://docs.example.com"))
Or simply use an <a> tag with a full URL — the runtime only intercepts same-origin links:
a([href("https://docs.example.com"), target("_blank")],
[text("Documentation")])
Advanced: Query Parameters
Use the Query dictionary on Url for search, filters, and pagination:
public static (Page Page, Command Command) FromUrl(Url url) =>
url.Path switch
{
["search"] =>
(new Page.Search(
Query: url.Query.GetValueOrDefault("q", ""),
PageNumber: int.TryParse(
url.Query.GetValueOrDefault("page", "1"), out var p) ? p : 1
),
new FetchSearchResults(
url.Query.GetValueOrDefault("q", ""),
int.TryParse(url.Query.GetValueOrDefault("page", "1"), out var pg) ? pg : 1)),
// ...
};
Real-World Example: Conduit Routing
The Conduit demo uses the same pattern at scale:
// From Picea.Abies.Conduit.App/Route.cs
public static (Page Page, Command Command) FromUrl(
Url url, Session? session, string apiUrl) =>
url.Path switch
{
[] or [""] => HomeRoute(session, apiUrl),
["login"] => LoginRoute(),
["register"] => RegisterRoute(),
["settings"] => SettingsRoute(session),
["editor"] => EditorRoute(null, session?.Token, apiUrl),
["editor", var slug] => EditorRoute(slug, session?.Token, apiUrl),
["article", var slug] => ArticleRoute(slug, session?.Token, apiUrl),
["profile", var user] => ProfileRoute(user, false, session?.Token, apiUrl),
["profile", var user, "favorites"]
=> ProfileRoute(user, true, session?.Token, apiUrl),
_ => (new Page.NotFound(), Commands.None)
};
Notice how route parameters (slug, user) are captured directly in the pattern match. No route parameter parsing library needed.
Testing
[Test]
public async Task FromUrl_Home_ReturnsHomePage()
{
var url = new Url([], new Dictionary<string, string>(),
Option<string>.None);
var (page, command) = Route.FromUrl(url);
await Assert.That(page).IsTypeOf<Page.Home>();
await Assert.That(command).IsEqualTo(Commands.None);
}
[Test]
public async Task FromUrl_UserProfile_ReturnsLoadingPage_AndFetchCommand()
{
var url = new Url(["users", "alice"],
new Dictionary<string, string>(), Option<string>.None);
var (page, command) = Route.FromUrl(url);
await Assert.That(page).IsTypeOf<Page.UserProfile>();
var profile = (Page.UserProfile)page;
await Assert.That(profile.Username).IsEqualTo("alice");
await Assert.That(profile.IsLoading).IsTrue();
await Assert.That(command).IsTypeOf<FetchUser>();
}
[Test]
public async Task UrlChanged_UpdatesPage()
{
var model = new Model(new Page.Home());
var url = new Url(["about"],
new Dictionary<string, string>(), Option<string>.None);
var (newModel, _) = App.Transition(model, new UrlChanged(url));
await Assert.That(newModel.CurrentPage).IsTypeOf<Page.About>();
}
[Test]
public async Task FromUrl_UnknownPath_ReturnsNotFound()
{
var url = new Url(["nonexistent", "path"],
new Dictionary<string, string>(), Option<string>.None);
var (page, _) = Route.FromUrl(url);
await Assert.That(page).IsTypeOf<Page.NotFound>();
}
Exercises
-
Add a search page — Create a
/search?q=termroute that reads the query parameter and triggers a search command. -
Protected routes — Add a
Session?parameter to your route function. Redirect unauthenticated users to/loginwhen they try to access protected pages. -
Breadcrumbs — Build a breadcrumb component that derives navigation links from the current URL path segments.
-
404 with suggestions — On the NotFound page, show links to routes that are similar to the attempted path.
Key Concepts
| Concept | In This Tutorial |
|---|---|
UrlChanged(Url) | Built-in message for URL changes |
Url.Path | Array of path segments for pattern matching |
Route.FromUrl(url) | Pure function: URL → (Page, Command) |
Navigation.PushUrl | Programmatic navigation command |
Navigation.ReplaceUrl | Replace current history entry |
Navigation.UrlChanges | Subscription for URL change events |
| Link interception | Regular <a href> links are intercepted automatically |
Next Steps
→ Tutorial 5: Forms — Learn form input handling, validation, and submission