Skip to main content

Routing and redirects

Vox.Routing is an optional projection-publication add-on. It turns application-defined route documents into an atomic lookup index with uniqueness, aliases, and historical redirects. Vox core remains unaware of URL structure, domains, CMS hierarchy, and path conventions.

Slugs, paths, and URLs​

A slug is one localized path segment such as coffee-makers. A path is an application-relative route such as /categories/kitchen/coffee-makers. An absolute URL additionally has a scheme and authority. Routable entities keep only the segment:

public class Category : IVoxEntity, IVoxRoutable
{
public VoxVarying<IVoxLanguage, string> Slug { get; set; } = new();
public VoxReference<Category>? Parent { get; set; }
}

The default normalizer requires paths to begin with /. It rejects query strings, fragments, backslashes, dot segments, control characters, and encoded separators; collapses repeated/trailing separators; normalizes Unicode; and applies the configured case policy. A slug must be one non-empty segment. Applications can replace IVoxRouteNormalizer, but writes and lookups always use the same instance.

Publish complete route claims​

A normal entity projection publishes a VoxRouteDocument:

[VoxEntityProjection("routes", "1")]
[VoxEntityProjectionPublication]
public class CategoryRoutes(IVoxRouteNormalizer normalizer)
: IVoxEntityProjectionFactory<Category, VoxRouteDocument>
{
public async Task<VoxRouteDocument?> ProjectAsync(
Category category,
IVoxEntityProjectionContext context,
CancellationToken cancellationToken)
{
var languages = await context.Calculations.GetAllAsync<Language>(cancellationToken);
var claims = new List<VoxRouteClaim>();
foreach (var language in languages)
{
// Walk category.Parent with context.Calculations.GetAsync<Category>.
// Every read becomes a dependency of this output.
var path = await BuildPathAsync(category, language, context, cancellationToken);
claims.Add(new VoxRouteClaim(
"storefront",
path,
new Dictionary<string, string>
{
[nameof(Language)] = language.Id,
}));
}
return claims.Count == 0 ? null : new VoxRouteDocument(claims);
}
}

This is an unscoped publication: one evaluation publishes all localized routes for the source entity, so context.Contexts is empty. Each claim's Contexts instead records the exact destination context selection that the routing endpoint must use later. The key is the concrete registered context type name (Language here), and the value comes from the language whose path was built. Do not hard-code a language unless the application intentionally publishes only that language.

Space is an application-defined namespace, normally a site or domain. (Space, NormalizedPath) is unique across all source entity types and projection publications; the same path can exist in independent spaces. Contexts is the exact concrete Vox context selection used to deliver the destination. A null Projection selects the entity's root projection, while a name selects an explicit content-delivery projection.

A document can publish localized routes and aliases. It must contain exactly one canonical claim for each space and context selection. Missing localized slugs produce no route unless the application deliberately implements a fallback in its factory.

Dependency-driven hierarchy updates​

Parent discovery is explicit application logic. Read every parent relation, slug, prefix setting, or site entity via context.Calculations. Vox records those reads. Changing an ancestor slug or moving a branch then republishes each dependent descendant route without saving the descendants, incrementing their entity versions, or writing unrelated history.

Full builds atomically replace a publication target and remove owners not seen in the build. Incremental sessions atomically diff individual owners. A collision or failed/disposed session leaves the previous visible index intact, and retrying a completed build ID is idempotent.

Route storage and high-traffic delivery​

IVoxRouteStore is the storage boundary used by route publication and lookup. Its publication sessions must expose a complete build atomically, enforce (Space, NormalizedPath) uniqueness, retain redirects, and make completed build IDs idempotent. Resolution reads use the same normalized keys written by the publication sink.

The bundled durable implementation is EntityFrameworkVoxRouteStore, registered with AddEntityFrameworkRouting. It stores routes and redirects in SQL and is the default choice for ordinary installations. InMemoryVoxRouteStore is also available for tests and ephemeral hosts, but it loses the complete route index when the process stops.

Applications with a high-volume or geographically distributed delivery tier can replace the SQL store by registering their own IVoxRouteStore implementation after AddVoxRouting. Register exactly one route store: Vox validates this when Build() runs. A custom store can use Redis, a dedicated routing database, or another lookup system as long as it preserves the interface's atomic publication and collision semantics.

vox.AddVoxRouting();
vox.Services.AddScoped<IVoxRouteStore, HighTrafficRouteStore>();

An official Redis-backed IVoxRouteStore implementation is coming.

Aliases and historical redirects​

An alias is a current VoxRouteKind.Alias claim owned by the document and resolves to its canonical claim. A historical redirect is created by the default IVoxRedirectPolicy only when one removed canonical route has exactly one replacement in the same space and equivalent contexts. Redirect chains are flattened to the latest canonical destination and loops are rejected.

Deletion, unpublishing, removing a language, and ambiguous split/merge changes do not invent redirects. A new current claim may reclaim a historical redirect source; the current route wins. Redirect delivery uses 308 by default, or 301 when explicitly configured.

Projection delivery​

Register the add-on and map an explicit catch-all:

vox.AddVoxRouting()
.AddEntityFrameworkRouting(options => options.ConfigureDbContext = ConfigureRoutes)
.AddVoxRoutingApi(options => options.DefaultSpace = "storefront");

app.MapVoxRoutes("/content/{**path}");

The endpoint resolves the stored entity, contexts, and projection, then uses the same delivery component as the entity-ID projection API. Read permissions, not-found behavior, sensitive-value redaction, rich-text formatting, and reference expansion therefore remain consistent. Stored contexts are authoritative; callers cannot override them with ?context=.

The initial route index contains published content only. Draft and release previews continue to use direct entity projection delivery; they are not silently mixed into public routing. Publication state and failures remain visible through the entity-projection-publication jobs and API.