Skip to content

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.

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:

  1. Same origin — serve the UI from the API host (UseGenieSpa) or behind one reverse proxy. CORS never applies. Zero preflights.
  2. 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 configured
app.UseGenieSpa(); // static assets + SPA fallback; no-op while Genie:Spa:Enabled is false
app.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) get Cache-Control: public, max-age=31536000, immutable; everything else (notably index.html) gets no-cache so a new deployment is picked up on the next navigation;
  • adds the SPA fallback: unknown, extension-less paths (e.g. /object/Orders on a hard refresh) serve index.html — this is the fallback routing: "browser" requires;
  • keeps API URL space clean: unmatched paths under the reserved prefixes (/api, /hubs, /files, /hangfire, /swagger, /openapi by 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 / *.sql

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; // UseGenieCors
using Genie.Engine.Hosting; // UseGenieSpa
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGenie<YourContext>(genie => genie
.LoadFromConfiguration(builder.Configuration)); // binds Genie:Cors and Genie:Spa too
builder.Services.AddGenieAuth(builder.Configuration);
builder.Services.AddControllers()
.AddApplicationPart(typeof(Genie.Engine.AssemblyMarker).Assembly);
var app = builder.Build();
app.UseGenieExceptionHandler(); // 1. errors → JSON envelope, wraps everything
app.UseGenieCors(); // 2. Genie:Cors, before auth (preflights are anonymous)
app.UseGenieSpa(); // 3. UI assets short-circuit here, before auth cost
app.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.)

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>
Terminal window
dotnet publish api -c Release -o out
# out/ now contains the API + out/ui/index.html + out/ui/assets/* — deploy it as one unit
ASPNETCORE_ENVIRONMENT=Production dotnet out/YourApp.Api.dll

The 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.

  • Dev: dotnet run the API + npm run dev the UI. Vite’s proxy keeps the browser same-origin; no CORS, hot reload as usual.
  • Try production mode locally: npm run build in ui/, run the API with Genie__Spa__Enabled=true (env var — no config edit), browse the API port directly. The network tab should show zero OPTIONS requests.
  • Deploy: dotnet publish (the target above) → run with ASPNETCORE_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.