Deployment — same-origin hosting & CORS
How you pair the built React UI with the Genie API in production decides whether every API call
pays a CORS preflight — an extra OPTIONS round-trip (often 100–300 ms) before the real
request. This page covers the three deployment shapes, from best to fallback.
Why every cross-origin request preflights
Section titled “Why every cross-origin request preflights”The Genie client sends Authorization, X-TimeZone, and X-XSRF-TOKEN headers plus
Content-Type: application/json, with credentials included. Any one of those makes a request
“non-simple”, so when the UI and the API are on different origins the browser sends a preflight
OPTIONS first — for every endpoint, on every page. In dev you never see this: the Vite proxy makes
the API same-origin. In production there is no Vite server, so an ad-hoc split-origin deployment
suddenly pays the tax.
The fix, in order of preference:
- Same origin — serve the UI from the API host (
UseGenieSpa) or behind one reverse proxy. CORS never applies. Zero preflights. - Cross-origin with preflight caching — configure
Genie:Cors; the browser caches each endpoint’s preflight for up to two hours instead of re-asking per request.
The UI needs no change either way: apiBase is a relative path (/api/v1/genie), so it calls
whatever origin served it.
Option 1 — the API host serves the UI (UseGenieSpa)
Section titled “Option 1 — the API host serves the UI (UseGenieSpa)”Build the UI, point the engine at the output, flip one flag:
"Genie": { "Spa": { "Enabled": true, "RootPath": "../ui/dist" // UI build output, relative to the API content root; omit ⇒ wwwroot }}app.UseGenieExceptionHandler();app.UseGenieCors(); // still safe to keep — no-op cross-origin unless origins are configuredapp.UseGenieSpa(); // static assets + SPA fallback; no-op while Genie:Spa:Enabled is falseapp.UseAuthentication();app.UseAuthorization();app.MapControllers();app.MapGenieHubs();UseGenieSpa (also called by MapGenieApp for umbrella hosts, so config alone is enough there):
- serves the build output as static files — content-hashed bundles (Vite’s
/assets) getCache-Control: public, max-age=31536000, immutable; everything else (notablyindex.html) getsno-cacheso a new deployment is picked up on the next navigation; - adds the SPA fallback: unknown, extension-less paths (e.g.
/object/Orderson a hard refresh) serveindex.html— this is the fallbackrouting: "browser"requires; - keeps API URL space clean: unmatched paths under the reserved prefixes (
/api,/hubs,/files,/hangfire,/swagger,/openapiby default) return the standard Genie JSON 404 envelope ({ success: false, error, traceId }), never HTML; - fails fast at startup when enabled but the index document is missing, with the fix in the message.
All knobs bind from Genie:Spa (code overrides via ConfigureSpa(...) on the GenieBuilder win):
| Key | Default | Purpose |
|---|---|---|
Enabled |
false |
Master switch — opt-in; reverse-proxy hosts leave it off. |
RootPath |
(wwwroot) | UI build output directory, relative to the host content root, e.g. "../ui/dist". |
IndexFile |
"index.html" |
The SPA entry document served for fallback routes. |
ApiPathPrefixes |
/api, /hubs, /files, /hangfire, /swagger, /openapi |
Prefixes that never fall back to the SPA (JSON 404 instead). |
ImmutablePathPrefixes |
/assets |
Prefixes holding content-hashed bundles ⇒ cached immutable. |
ImmutableMaxAgeSeconds |
31536000 |
Cache lifetime for immutable assets (1 year). |
The Inventory sample ships this pre-wired: cd ui && npm run build,
set Genie:Spa:Enabled to true, dotnet run the API, and browse the API origin directly.
Integrating it into your own host, end-to-end
Section titled “Integrating it into your own host, end-to-end”A complete walkthrough for a typical caller project — the same shape as the Inventory sample:
your-app/ api/ ASP.NET Core host (references Genie.Engine) Program.cs appsettings.json appsettings.Production.json YourApp.Api.csproj ui/ React host (installs @orbyn-technologies/genie-engine-ui) src/main.tsx createGenieApp({ apiBase: "/api/v1/genie", routing: "browser", … }) vite.config.ts dev proxy for /api, /files, /hubs → the API port models/ *.entity.xml / *.view.xml / *.navbar.xml / *.sql1. The host pipeline
Section titled “1. The host pipeline”Nothing about the UI is referenced in code — UseGenieSpa() reads everything from config and is a
no-op until enabled, so the same Program.cs serves dev (API only, Vite serves the UI) and
production (API serves the UI):
using Genie.Engine;using Genie.Engine.Features.Identity.Extensions; // UseGenieCorsusing Genie.Engine.Hosting; // UseGenieSpa
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGenie<YourContext>(genie => genie .LoadFromConfiguration(builder.Configuration)); // binds Genie:Cors and Genie:Spa toobuilder.Services.AddGenieAuth(builder.Configuration);builder.Services.AddControllers() .AddApplicationPart(typeof(Genie.Engine.AssemblyMarker).Assembly);
var app = builder.Build();
app.UseGenieExceptionHandler(); // 1. errors → JSON envelope, wraps everythingapp.UseGenieCors(); // 2. Genie:Cors, before auth (preflights are anonymous)app.UseGenieSpa(); // 3. UI assets short-circuit here, before auth costapp.UseAuthentication();app.UseAuthorization();app.MapControllers();app.MapGenieHubs();app.Run();(An AddGenieApp/MapGenieApp umbrella host needs no UseGenieSpa() call at all —
MapGenieApp applies it from config.)
2. Per-environment config
Section titled “2. Per-environment config”appsettings.json — SPA hosting off; dev runs the Vite server with its proxy, so dev is already
same-origin and CORS stays silent:
"Genie": { "Spa": { "Enabled": false, "RootPath": "../ui/dist" // used when you flip Enabled locally to try production mode }}appsettings.Production.json — SPA hosting on, pointing at the folder the publish step creates
(next section):
"Genie": { "Spa": { "Enabled": true, "RootPath": "ui" }}No Genie:Cors section is needed in either file: with SPA hosting there is no cross-origin
caller, and the empty-origins default already rejects any stray one.
3. One deployable: fold the UI build into dotnet publish
Section titled “3. One deployable: fold the UI build into dotnet publish”Add a publish target to the API’s .csproj so dotnet publish builds the UI and ships it under
ui/ in the publish output — one artifact to deploy, no separate UI pipeline:
<Target Name="PublishGenieUi" BeforeTargets="ComputeFilesToPublish"> <Exec Command="npm ci" WorkingDirectory="..\ui" /> <Exec Command="npm run build" WorkingDirectory="..\ui" /> <ItemGroup> <GenieUiDist Include="..\ui\dist\**" /> <ResolvedFileToPublish Include="@(GenieUiDist)"> <RelativePath>ui\%(RecursiveDir)%(Filename)%(Extension)</RelativePath> <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> </ResolvedFileToPublish> </ItemGroup></Target>dotnet publish api -c Release -o out# out/ now contains the API + out/ui/index.html + out/ui/assets/* — deploy it as one unitASPNETCORE_ENVIRONMENT=Production dotnet out/YourApp.Api.dllThe published content root is the app folder, so RootPath: "ui" resolves to out/ui. If the
folder is missing (UI build skipped), the host fails at startup with a message naming the path —
it won’t silently serve 404s.
4. The day-to-day loop
Section titled “4. The day-to-day loop”- Dev:
dotnet runthe API +npm run devthe UI. Vite’s proxy keeps the browser same-origin; no CORS, hot reload as usual. - Try production mode locally:
npm run buildinui/, run the API withGenie__Spa__Enabled=true(env var — no config edit), browse the API port directly. The network tab should show zeroOPTIONSrequests. - Deploy:
dotnet publish(the target above) → run withASPNETCORE_ENVIRONMENT=Production.
The runnable reference for all of this is the
Inventory sample (sample/Inventory in the repo): its
RequestPipeline.cs shows the pipeline order, appsettings.json carries the Genie:Spa block, and
appsettings.Development.json demonstrates the cross-origin fallback config.
Option 2 — one reverse proxy in front of both
Section titled “Option 2 — one reverse proxy in front of both”Identical result (one origin, zero preflights) when you’d rather keep the API process serving only JSON. Route the reserved prefixes to the API and serve static files for everything else — this is exactly what the Vite dev proxy does, productionized. nginx:
server { listen 443 ssl; server_name app.example.com;
root /var/www/genie-ui; # the UI build output index index.html;
location ~ ^/(api|files)/ { proxy_pass http://127.0.0.1:5184; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }
location /hubs/ { # SignalR needs the WebSocket upgrade proxy_pass http://127.0.0.1:5184; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }
location /assets/ { # content-hashed bundles add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; }
location / { # SPA fallback for client routes add_header Cache-Control "no-cache"; try_files $uri /index.html; }}With YARP, the equivalent is a catch-all static-files app plus routes for /api/{**rest},
/files/{**rest}, and /hubs/{**rest} (WebSockets proxy transparently) to the API cluster.
Option 3 — genuinely cross-origin (Genie:Cors)
Section titled “Option 3 — genuinely cross-origin (Genie:Cors)”When the UI must live on a different domain (e.g. a CDN), configure the engine’s CORS policy —
registered automatically by AddGenie/AddGenieApp — and apply it with app.UseGenieCors()
(after UseGenieExceptionHandler, before UseAuthentication: preflights are anonymous):
"Security": { "Cors": { "AllowedOrigins": [ "https://ui.example.com" ], "AllowCredentials": true }}| Key | Default | Purpose |
|---|---|---|
AllowedOrigins |
[] |
Origins allowed to call the API. Empty = no cross-origin callers (the safe default); same-origin traffic is unaffected. |
AllowedMethods |
GET, POST, PUT, PATCH, DELETE, OPTIONS |
Methods allowed on CORS calls. |
AllowedHeaders |
Authorization, Content-Type, X-Requested-With, X-Correlation-Id, X-TimeZone, X-XSRF-TOKEN, X-SignalR-User-Agent |
Request headers allowed — the default covers everything the Genie client sends, including the header the SignalR browser client adds to hub negotiate requests. |
ExposedHeaders |
[] |
Response headers readable by cross-origin script. |
AllowCredentials |
false |
Required for the Genie client (it sends credentials). Only honoured with explicit origins — never paired with *. |
PreflightMaxAgeSeconds |
7200 |
Access-Control-Max-Age — how long the browser caches a preflight verdict. Chromium caps at 7200, Firefox at 86400. |
Code overrides win via the builder: genie.ConfigureCors(c => c.AllowedOrigins = [...]).
With PreflightMaxAgeSeconds the browser re-preflights each endpoint URL at most once per cache
window instead of before every request — a large improvement, but not zero: the preflight cache is
per-URL, so the first hit on each endpoint still pays one OPTIONS.
What next
Section titled “What next”- Backend integration — the host pipeline these calls slot into.
- Configuration reference — every appsettings section a host can
set, including the full
Genie:SpaandGenie:Corstables above in context. - Frontend configuration —
apiBaseandrouting: "browser". - Identity — cookies, JWT, and the Data-Protection pairing rule.