-
Notifications
You must be signed in to change notification settings - Fork 1
ServerIntegration
🌐 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.
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() 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
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); // DisableSince 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
options.UseThemeCookie(name: "acme-theme", days: 30); // rename / shorten
options.WithoutThemeCookie(); // never write oneIt 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 inelement.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 =>
{
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\">");
});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 |
| 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.
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.
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.
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();
}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.
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.
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.
Enables services and CDN script endpoints for chart libraries.
builder.Services.AddUI(options =>
{
options.UseChartJs()
.UseApexCharts();
});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
: pingcomment every 20s so idle-dropping proxies and Kestrel keep the parked request alive; the browser's ownEventSourcereconnection 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.
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).
When SSR is enabled, the framework:
- Finds the matching
[Page]component for the route - Creates the component instance (with DI support)
- Collects asset dependencies (see Assets)
- Collects SEO metadata (see below)
- Renders the component tree to HTML
- Serializes state for client-side hydration
- Serves the complete HTML page
Since 0.2.0-preview.1
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.
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.
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.
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.
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.
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
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.
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
hreflanglooks right in the markup and is dropped by every crawler, so a policy that answers/es/pricinggets the request's scheme and host. -
x-defaultnames 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)🌐 English · Português
🏁 Start here
📱 Write-once
- Write-Once Components
- Declarative Surface
- Photon Engine
- Design System
- Capabilities
- Storage
- Forms
- Code Editor
- Markdown
- Mermaid
- Email Rendering
🏗️ Architecture
⚙️ Compilation
- Compiler
- Compile-Time Evaluation
- Supported C# Features
- External Type Resolution
- Build Flow
- Diagnostics
⚡ Runtime
🔌 Server
🎨 Ecosystem
🚀 Development