Server IntegrationeQuantic.UI integrates with ASP.NET Core through a fluent API for service registration, middleware configuration, and HTML shell customization.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddUI(options =>
options.ScanAssembly(typeof(Program).Assembly)
.UseTheme(PhotonTheme.Instance) // Write-once theme
.UseApexCharts() // Charts
.ConfigureHtmlShell(shell =>
.AddHeadTag("<meta name=\"theme-color\" content=\"#3b82f6\">");
var app = builder.Build();
app.UseServerActions(); // Server Actions middleware
app.MapUI(); // SPA routing & package endpoints
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
Fluent configuration API for the UI framework.
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);
Enables or disables Server-Side Rendering globally. Default is true.
options.WithSsr(); // Enable (default)
options.WithSsr(false); // Disable
Since 0.2.0-preview.1
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 { }
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. Configures the HTML template that wraps all pages.
options.ConfigureHtmlShell(shell =>
.SetBaseStyles("body { margin: 0; }")
.AddHeadTag("<link rel=\"icon\" href=\"/favicon.ico\">")
.AddHeadTag("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
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();
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
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.
Registers the Server Actions middleware for handling RPC calls from the browser.
Server Actions are methods marked with [ServerAction] that execute server-side and return results to the client:
public async Task<List<Todo>> LoadTodos()
using var db = new AppDbContext();
return await db.Todos.ToListAsync();
Maps SPA routing: every [Page] route gets an endpoint, and a fallback serves the HTML shell for everything else.
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 =>
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;
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.
…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
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.
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:
Description(string)
Meta description
Canonical(string)
Canonical URL
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
.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) =>
.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.
The recommended way to register UI features is within the AddUI fluent block:
builder.Services.AddUI(options =>
.ScanAssembly(typeof(Program).Assembly);
Middleware order:
app.UseServerActions(); // Before MapUI
app.MapUI(); // SPA fallback + Package endpoints (last)