Skip to content

ServerIntegration

Edgar Mesquita edited this page Aug 24, 2026 · 29 revisions

Server Integration

🌐 This page in: English · Português

eQuantic.UI integrates with ASP.NET Core through a fluent API for service registration, middleware configuration, and HTML shell customization.

Quick Start

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddUI(options =>
{
    options.ScanAssembly(typeof(Program).Assembly)
           .WithSsr()
           .UseTheme(PhotonTheme.Instance)  // Write-once theme
           .UseChartJs()               // Charts
           .UseApexCharts()            // Charts
           .ConfigureHtmlShell(shell =>
           {
               shell.SetTitle("My App")
                    .SetHtmlClass("dark")
                    .AddHeadTag("<meta name=\"theme-color\" content=\"#3b82f6\">");
           });
});

var app = builder.Build();

app.UseStaticFiles();
app.UseServerActions();                // Server Actions middleware
app.MapUI();                           // SPA routing & package endpoints

app.Run();

AddUI

AddUI() is the main entry point that registers all core services:

builder.Services.AddUI(options => { ... });

What it registers:

  • UIOptions (singleton) - Configuration
  • IServerActionRegistry - Scans assemblies for [ServerAction] methods
  • IServerActionAuthorizationService - Authorization for server actions
  • IServerRenderingService - SSR rendering engine
  • IAppTheme - The selected write-once theme (UseTheme(...); PhotonTheme by default, see DesignSystem)
  • IComponentAssetProvider<T> - Auto-scanned from assemblies (see Assets)
  • IThemeController - The light/dark hand during SSR (UseInitialThemeMode(...)); the browser's own takes over at hydration
  • SignalR services

UIOptions

Fluent configuration API for the UI framework.

ScanAssembly

Scans an assembly for [Page] components, [ServerAction] methods, and IComponentAssetProvider<T> implementations.

options.ScanAssembly(typeof(Program).Assembly);

Multiple assemblies can be scanned:

options.ScanAssembly(typeof(Program).Assembly)
       .ScanAssembly(typeof(SharedComponents).Assembly);

WithSsr

Enables or disables Server-Side Rendering globally. Default is true.

options.WithSsr();           // Enable (default)
options.WithSsr(false);      // Disable

Since 0.2.0-preview.1

UseInitialThemeMode

The light/dark mode the server renders in, which is what the browser paints before any JavaScript runs. Light unless set.

options.UseInitialThemeMode(ThemeMode.Dark);

It is a DEFAULT, not a fixed value: a visitor who has toggled the theme carries a cookie (eq-theme), and that wins. The browser's own controller writes it from document.cookie, which costs nothing and needs no round trip per toggle, and the server reads it, which is the whole reason it is a cookie and not localStorage: the requirement is not "remember" but tell the server. Remembering in localStorage works perfectly and the server cannot see a word of it, so the page would arrive in the default mode and be corrected at hydration, which is the flash this removes.

An unrecognised cookie value is ignored rather than trusted: it is user-supplied text, and this question has exactly two answers.

Since 0.2.0-preview.11

The theme cookie is configurable, including off

options.UseThemeCookie(name: "acme-theme", days: 30);   // rename / shorten
options.WithoutThemeCookie();                            // never write one

It is ONE setting because both halves must agree: the browser's controller writes this cookie and the server reads it. Configure them separately and they drift, at which point the server reads a name nobody writes: persistence stops working while every part of it still looks correct. The setting crosses to the browser in the page's own config for exactly that reason.

Worth renaming when two eQuantic apps share a domain and should not inherit each other's theme, or when a site already has a cookie convention.

WithoutThemeCookie() and consent. Whether a preference cookie needs consent under the GDPR or the LGPD depends on your jurisdiction and your own assessment. The framework does not make that call for you, it gives you the switch. With it off nothing is written and the toggle still works: the mode applies to the page in front of the visitor, it simply does not outlive it, so every visit starts from UseInitialThemeMode or the OS.

Note the fence: this is a build-time switch. An app that wants to start persisting the moment a visitor accepts a banner needs its own hand on the writing: the framework does not yet expose a runtime consent hook.

This exists so nothing has to guess. A component offering a theme toggle resolves IThemeController; in the browser that is the controller which stamps data-theme, but during SSR there is no browser, and a component that resolved nothing had to assume a mode, an assumption that decides the markup the reader sees first, so guessing wrong means the first paint is the wrong theme and hydration corrects it in front of them.

What the toggle reads matters as much as what it writes. A toggle asks the controller for the current mode and applies the other one, so a controller that reports the wrong mode applies the mode the page is already in: the first click does nothing and the visitor clicks twice. The browser's controller therefore reads, in order: its own inline style (a live choice), then the computed color-scheme (which is how a server-declared mode arrives, as a stylesheet rule that never appears in element.style), then the OS. Since 0.2.0-preview.12

Applying a mode on the server is deliberately inert, and the mode is read per request rather than captured: the controller is a singleton, so a captured value would hand one visitor's choice to the next visitor's first paint. An app that wants per-request memory (a cookie, a header) registers its own IThemeController, and this one is registered with TryAdd, so yours wins.

Individual pages can opt-out:

[Page("/interactive", DisableSsr = true)]
public class InteractivePage : StatefulComponent { }

WithAssetProvider<T>

Explicitly registers an IComponentAssetProvider<T> implementation. Useful for providers from external assemblies not covered by ScanAssembly.

options.WithAssetProvider<ChartJsAssetProvider>();

See Assets for details on the asset provider system.

ConfigureHtmlShell

Configures the HTML template that wraps all pages.

options.ConfigureHtmlShell(shell =>
{
    shell.SetTitle("My App")
         .SetHtmlClass("dark")
         .SetBaseStyles("body { margin: 0; }")
         .AddHeadTag("<link rel=\"icon\" href=\"/favicon.ico\">")
         .AddHeadTag("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
});

The provenance header

Since 0.2.0-preview.29

Every response the app sends (pages, Server Actions, static bundles, even the 404) carries x-powered-by: eQuantic.UI. It installs itself: AddUI registers a startup filter, so no Program.cs mentions it. The value is the name alone (a version in a response header is a gift to vulnerability scanners), and it never overwrites an x-powered-by something else already set.

// For the app whose hardening checklist flags any x-powered-by at all:
options.WithoutPoweredByHeader();

Properties

Property Type Default Description
EnableSsr bool true Global SSR toggle
EnableDefaultCss bool true Inject default eQuantic CSS (set false with Tailwind)
HtmlShell HtmlShellOptions - HTML template configuration

HtmlShellOptions

Method Description
SetTitle(string) Page <title>
SetHtmlClass(string) Class on <html> element (e.g., "dark")
SetBaseStyles(string) Base <style> block
AddHeadTag(string) Raw HTML tag injected into <head>

SetBaseStyles replaces only the COSMETIC defaults. The structural invariant (#app as a determinate frame: height: 100dvh; display: grid, children min-height: 0, the web mirror of the native window) is emitted by the shell template itself, before app styles. An APP page (root Height = Fill) gets exactly one viewport and scrolls internally; a DOCUMENT page (auto-height root) overflows the frame and the body scrolls as it always did. An app can still override the rule deliberately; it cannot wipe it by accident.

MapPage: a route declared beside every other endpoint

Since 0.2.0-preview.13

app.MapPage<HomePage>("/");
app.MapPage<DocPage>("/docs/{slug}", title: "Docs");

The [Page("/route")] attribute stays, and for a page whose route is part of what it IS (a 404, a login) it remains the better answer. MapPage<T> is for the rest: routes an app wants to read in one place, routes that differ between hosts, a page mounted at a path its own file has no business knowing. It is also the only way to route a page from an assembly you do not own.

Declare it where every other endpoint is declared, before app.Run(). The route registers in all three places a route has to exist (the endpoint table, the SSR page index, and the client's table for SPA navigation) so nothing downstream can tell the two ways of declaring a route apart. A route that only half-registers is worse than none: the page serves, and then the first client-side link to it reloads the whole document for no visible reason.

A type that is not a component throws at startup naming itself, rather than on the first request to a route nobody can serve.

A page is built from the REQUEST's services

Since 0.2.0-preview.13

builder.Services.AddScoped<IOrders, Orders>();   // a DbContext, a unit of work, the current tenant

public sealed class OrdersPage(IOrders orders) : StatelessComponent {}

Pages and server actions are constructed from context.RequestServices. This is a fix, not a feature: both used to build from the application's root container, and .NET refuses to hand a scoped service out of the root by design, because a scoped service resolved there outlives the request and is then shared by every later one.

The server-action path is where it hurt most, since that is precisely where the scoped things live. It surfaced as a 500 reading "An error occurred while processing the request."

Invisible until the container is asked to check: a container built with the default options hands scoped services out of the root perfectly happily, and ASP.NET Core only validates in Development.

A page whose own constructor throws now says so. That failure used to be swallowed and the page quietly rebuilt with nothing injected: it rendered its empty state as if it had asked for nothing, the dependency was null, and the exception that explained it was gone.

Middleware

UseServerActions

Registers the Server Actions middleware for handling RPC calls from the browser.

app.UseServerActions();

Server Actions are methods marked with [ServerAction] that execute server-side and return results to the client:

[ServerAction]
public async Task<List<Todo>> LoadTodos()
{
    using var db = new AppDbContext();
    return await db.Todos.ToListAsync();
}

MapUI

Maps SPA routing: every [Page] route gets an endpoint, and a fallback serves the HTML shell for everything else.

app.MapUI();

On the client, navigation is a full SPA router: no-reload navigation, typed route params, persistent layout via reconcile-on-navigate, guards, prefetch and scroll restoration, verified end-to-end by the Playwright suite.

The fallback answers an unknown route with a true HTTP 404, never a 200 that merely looks like one, so crawlers and monitors learn the truth. What renders with that status:

  • The app's own 404 page, when one is declared. Route an ordinary write-once page at "/404" and it becomes the not-found page: SSR'd and client-mounted for every unknown URL, with the app's theme, transpiled into the app's own bundle like any page:

    [Page("/404", Title = "Not found | My App")]
    public sealed class NotFoundScreen : StatelessComponent
    {
        public override VisualNode Build(ComponentContext context) => /* any page */;
    }

    A page routed at "/500" is registered the same way: in production, when SSR of the requested page fails, that page renders with status 500.

  • A styled built-in, otherwise. The runtime paints a minimal theme-aware not-found page (tokens via var(--eq-color-*), OS light/dark fallback when the app selected no theme). The same shared renderer backs the boot error page and the no-page welcome screen, and none of them depend on any stylesheet existing.

Browsing straight to /404 hits a mapped page and answers 200; only the fallback speaks 404.

RegisterServices / RegisterEndpoints

The seam a PACKAGE extends the app through, which is how UseChartJs() and friends are built, rather than something an app normally calls:

options.RegisterServices(services => services.AddSingleton<IMyThing, MyThing>());
options.RegisterEndpoints(endpoints => endpoints.MapGet("/_mine/thing.js",));

The first runs inside AddUI(), the second inside MapUI(), so a package ships one extension method and an app gains both its services and its routes from a single line.

Styling and icons need no registration

There is no UseTailwind() and no UseLucideIcons(). Both were real once and are gone, and the reason is worth knowing rather than guessing at:

  • Styling is one engine now: typed C# lowered to deduplicated atomic classes, described in Styling. Nothing is registered and no utility stylesheet is fetched. Whatever external CSS you bring is your own build's concern.
  • Icons are catalogs, not providers: you name the glyph (Glyph(LucideIcons.Search)) and the compiler inlines that one. There is no registry to add to and no name to resolve at run time. See Icons.

UseChartJs / UseApexCharts

Enables services and CDN script endpoints for chart libraries.

builder.Services.AddUI(options =>
{
    options.UseChartJs()
           .UseApexCharts();
});

Hot reload (web)

On in Development (or forced with options.HotReload = true): the server watches the app's *.cs, re-runs the SDK's own eqc target on a save, and tells every connected browser to refresh over SSE (/_equantic/hmr). ~5s from save to pixels with a warm MSBuild.

Behaviors that make the circuit reliable:

  • A reload triggered by hot reload MOUNTS (renders client-side with the new code) instead of hydrating, because the SSR still comes from the server's running assembly, so adopting the old DOM would show the old pixels.
  • The SSE channel sends a : ping comment every 20s so idle-dropping proxies and Kestrel keep the parked request alive; the browser's own EventSource reconnection handles transient drops.
  • The rebuild reads its output pipes concurrently and logs its duration.

Scope: this pipeline refreshes the CLIENT, while the server's own C# (server actions, SSR bodies) runs the loaded assembly. For server-side edits, run under dotnet watch run: .NET hot reload patches the running server in-process, and this pipeline keeps handling the client half.

The error overlay: a C# stack, because the developer wrote C#

An uncaught error in development raises the Next.js-style modal (message, code frame, call stack), except the stack it shows is C#: Screens/PaymentsPage.cs:441, with the failing lines of the C# file rendered and highlighted. A minified JS stack is noise from a machine the developer never asked to meet.

How it works: the browser walks the error's JS stack through TWO maps. The bundle's own .js.map lands in the TS intermediate (Bun does not compose input maps), and the eqc-generated .ts.map beside that intermediate lands in the C#: file, line, and the source text itself, embedded in the map, served in development at /_equantic/src-map/{name} and 404ing in production. Frames that cannot map all the way stay labeled (js), because a true statement about where mapping stopped beats a guessed C# line.

Precision is MEMBER-level: the frame lands in the right file and on the containing member's line (the emitter records mappings per member, not per statement).

Server-Side Rendering (SSR)

When SSR is enabled, the framework:

  1. Finds the matching [Page] component for the route
  2. Creates the component instance (with DI support)
  3. Collects asset dependencies (see Assets)
  4. Collects SEO metadata (see below)
  5. Renders the component tree to HTML
  6. Serializes state for client-side hydration
  7. Serves the complete HTML page

Since 0.2.0-preview.1

Server data on the first render (IServerPrefetch)

A page declares the data it needs, the SSR pipeline awaits it before building the tree, and the values travel to the browser so hydration sees exactly what the server rendered. The markup carries real numbers for crawlers, and the client never flashes an empty state into a filled one.

public sealed class HomePage : StatelessComponent, IServerPrefetch
{
    private PackageStats _stats = PackageStats.Empty;

    [ServerOnly]
    public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
        => _stats = await services.GetRequiredService<IPackageStats>().LoadAsync(cancellationToken);

    public override VisualNode Build(ComponentContext context) => new HeroSection(_stats);
}

Three things decide whether this works:

  • [ServerOnly] keeps the implementation out of the client bundle, so it may use the whole server surface: HttpClient, EF, the request's own services.
  • Store results in FIELDS. The hydration payload travels by field name into the identical fields of the transpiled twin. A property does not cross.
  • It runs once per request, before the first build, which is what makes it different from loading in a handler and calling SetState.

Native hosts render locally and prefetch nothing: a Photon shell loads the same data before constructing the tree, as an explicit call.

Every field is public

Since 0.2.0-preview.43

The payload is written into the served HTML. A value a prefetch stores is readable by anyone who views the page source — it stops being "server data" the moment it lands in a field.

Load what the page displays, and nothing else. The access token used to fetch, the connection string behind the query, the internal id you only needed while loading: none of those belong in a field.

A secret belongs in neither place. A [ServerAction] runs on the server and may use one — read a token, open a connection, call an API with it — but its return value is serialized to the browser exactly as a field is, so return the answer and never the secret that produced it. The rule is the same on both sides: what crosses is what the page may show.

What does not travel is a dependency: a field whose type is an interface from outside System, which the client resolves for itself — the same rule the compiler applies when it decides a constructor parameter is a capability rather than a value. The System exclusion is deliberate and not a detail: IReadOnlyList<T> is how a component receives its items, so skipping every interface would delete state rather than protect anything.

Everything else that can be written travels, including a string nobody meant to publish. A null, a delegate, and a value that fails to serialize are left out — but read that as what it is, a robustness rule so one bad field cannot empty the whole payload. It is not a protection: never rely on a value being unserializable to keep it off the page.

…and on a client NAVIGATION

Since 0.2.0-preview.21

A link inside a booted app never reaches the server, so for a while it swapped the component and nothing else: the prefetch did not run, and every navigated-to page rendered the empty state it was written to show while data loads, with nothing loading it. The head kept the previous document's title and canonical, which for a crawler is one page asserting that two URLs are the same document.

The router now asks the target route itself for the page's data, carrying a header:

GET /docs/Photon        X-EQ-Navigate: 1
→ { "title": "Photon | …", "head": "<link rel=canonical …>", "state": { … } }

Going to the route rather than to a side endpoint is the load-bearing part: the route params, the query and the page resolution are the ones a full load would have, because it IS the same route. A side endpoint taking a path would have had to reimplement all three.

The state arrives through the same door the SSR payload uses, so IServerPrefetch fields are populated before the first build. The head is patched by identity, on the attribute that names a tag (name, property, rel), never appended to, or the previous page's canonical would survive beside the new one. A failure is not fatal: the page then renders exactly what it rendered before this existed.

It does not draw. PreparePageAsync runs the same code the shell runs minus the markup: a client navigation has the component already and builds the tree itself, so HTML rendered here would be HTML thrown away. Affordable once per navigation, and not at all once per hovered link, which is what the next section is about.

A hovered link arrives warm

Since 0.2.0-preview.23

Pointing at an app link warms both halves of the navigation it suggests: the page bundle, and the payload above. The click that follows makes no request at all.

Measured on this wiki's own site, on a large document: 172 ms → 28 ms to the first DOM patch.

Three things had to be true, and two of them were quietly false for a long time:

  • A link has to invite it. The router has warmed routes on hover since it was written, gated on data-prefetch, and nothing in the framework ever marked a link: dead code, and every navigation paid for everything at click time. App-internal destinations carry it now; an absolute URL is somebody else's server and does not.
  • The warmed answer has to be FOUND. Measured first, and the warmed navigation came out slower than the cold one: the hover stored the payload under a string and the click looked it up with a URL object. A cache nobody hits is worse than no cache, because it costs the request it was there to save, and it looks like it is working.
  • The router asks once per link and swallows failures, so the worst case is work the click was about to do anyway, done slightly earlier.

What remains is the page's own build. On a big document that is most of the time, and no framework lever shortens it: the content decides.

A page that found nothing (IHandleStatus)

Since 0.2.0-preview.13

A route like /docs/{slug} matches every slug, including the ones naming no document. The page renders "not found", and without this the server still answers 200 OK, so the reader sees the right thing while every machine is told the wrong one. A crawler indexes the empty page, a link checker calls the site healthy, and an uptime probe never notices. The failure is invisible to exactly the things whose job is noticing.

public sealed class DocPage : StatelessComponent, IServerPrefetch, IHandleStatus
{
    private Doc? _doc;

    [ServerOnly]
    public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
        => _doc = await services.GetRequiredService<IDocs>().FindAsync(Slug, cancellationToken);

    public int StatusCode => _doc is null ? 404 : 200;
}

Read after the prefetch, because "does this exist" is something a page usually learns by loading it. A page that does not implement it answers 200, so nothing written before this changes. Native hosts have no status to answer with and ignore it; the tree is the same either way.

SEO & Metadata

Components implement IHandleMetadata for dynamic SEO:

public class BlogPost : StatelessComponent, IHandleMetadata
{
    public void ConfigureMetadata(SeoBuilder seo)
    {
        seo.Title("Blog Post Title")
           .Description("A summary of the post...")
           .Canonical("https://example.com/blog/post")
           .OpenGraph("type", "article")
           .Twitter("card", "summary_large_image");
    }
}

SeoBuilder methods:

Method Description
Title(string) Page title
Description(string) Meta description
Canonical(string) Canonical URL
Alternate(string, string) One language's URL in the translation group (hreflang). Writing the same language twice REPLACES it, so a page overrides the app-wide policy for that language alone
AlternateDefault(string) The x-default URL: where a visitor whose language matched nothing lands
Image(string, string?) The share image. Writes og:image AND twitter:image, plus both :alt variants when you pass one. Forgetting the Twitter half is why a card shows up blank in half the places it is pasted
Keywords(string) Meta keywords
Robots(bool, bool) Index/follow directives
OpenGraph(string, string) OG property
Twitter(string, string) Twitter card property

Since 0.2.0-preview.1

App-wide defaults, and a page overriding them

The shell states what every page should say unless it says otherwise; a page's own ConfigureMetadata overrides it by key, so the two never both appear.

builder.Services.AddUI(options => options
    .ConfigureHtmlShell(shell => shell
        .SetTitle("Acme")
        .ConfigureMetadata(seo => seo
            .Image("https://acme.test/og-default.png", "Acme")
            .Twitter("card", "summary_large_image"))));

A page then restates only what differs, and the card type and the fallback image above survive untouched:

public void ConfigureMetadata(SeoBuilder seo) =>
    seo.Title("Playground")
       .Description("Write a component in C#, press Run…")
       .Canonical("https://acme.test/playground")
       .Image("https://acme.test/og-playground.png");

This is worth stating plainly because it used not to work. AddDescription wrote raw HTML into the head, and raw HTML shares no key with anything, so an app with a global description and a page with its own shipped two <meta name="description">, and no page could win. The only way out was to leave the global empty, which made it useless for the one thing a global is for. Shell metadata now seeds the same collection the page writes into.

AddHeadTag is still the escape hatch for genuinely raw markup (a <link rel="icon">, a JSON-LD block). Anything with a metadata key belongs in ConfigureMetadata, or it cannot be overridden.

hreflang: one policy, every page

Since 0.2.0-preview.31

A localized site that never says which URL is which language is a site a crawler indexes as duplicates of one another, and it never complains: the pages rank against each other and the translation nobody asked for is the one that shows up. The fix is rel="alternate" hreflang, and it is an APP-WIDE fact, not a per-page one, so the app declares the URL policy once:

builder.Services.AddUI(options => options
    .UseAlternateLinks(AlternateUrls.PathPrefix(), "en", "pt-BR", "es"));

Every page then carries the whole group:

<link rel="alternate" hreflang="en" href="https://acme.test/en/pricing">
<link rel="alternate" hreflang="pt-BR" href="https://acme.test/pt-BR/pricing">
<link rel="alternate" hreflang="es" href="https://acme.test/es/pricing">
<link rel="alternate" hreflang="x-default" href="https://acme.test/en/pricing">

The three rules the standard imposes are enforced here rather than left to the app, because each one fails silently when it is broken:

  • The set includes the page ITSELF. A group that omits the current page is discarded whole, so a page always advertises its own language too, and every translation carries the identical set.
  • Every URL is absolute. A relative hreflang looks right in the markup and is dropped by every crawler, so a policy that answers /es/pricing gets the request's scheme and host.
  • x-default names where a visitor who matched nothing lands. It follows the app's own default culture when the middleware shared one, and otherwise the first language named.

Two URL shapes come ready, and either can be swapped for a lambda when a site spells it its own way (a subdomain, or a slug that is simply another page):

Policy Shape
AlternateUrls.PathPrefix() /pt-BR/pricing — the shape Google recommends; a segment that already names a culture is REPLACED, not stacked
AlternateUrls.QueryString() /pricing?culture=pt-BR — the rest of the query survives, only the culture key is replaced
r => … Anything, from r.Culture and r.Request; a relative answer still gets made absolute
options.UseAlternateLinks(r => $"https://{r.Culture.ToLowerInvariant()}.acme.test{r.Request.Path}");

Name the languages unless the app shares its localization options through DI. The overload almost every app uses — app.UseRequestLocalization(o => …) — builds its options inline and registers nothing, so asking the container answers with the invariant default and the head comes out empty. An app that calls services.Configure<RequestLocalizationOptions>(…) and then the argumentless app.UseRequestLocalization() keeps one list and can leave the names out.

A page can still override one language on its own — seo.Alternate("pt-BR", "…/precos") replaces the app's answer for that language and leaves the rest of the group intact, which is what makes an app-wide policy safe to turn on for a site with a handful of translated slugs.

Nothing is emitted when the app declared no policy, or when it has a single language: a translation group of one asserts the page exists in no other language, which is a claim, not an absence.

The recommended way to register UI features is within the AddUI fluent block:

builder.Services.AddUI(options =>
{
    options
        .UseChartJs()
        .UseApexCharts()
        .ScanAssembly(typeof(Program).Assembly);
});

Middleware order:

app.UseStaticFiles();
app.UseRouting();
app.UseServerActions();    // Before MapUI
app.MapUI();               // SPA fallback + Package endpoints (last)

Clone this wiki locally