Skip to content

Realtime (Next.js) Frontend

frontend/realtime is the modern, customer-facing Realtime web application (internally "RT3"). It is a Next.js 15 app (App Router) written in TypeScript and is the primary UI for end users (shipments, orders, bookings, quotations, insights, inbox, reports, map, admin). In the Aspire model it is registered as the realtime service.

It is a fully decoupled SPA-style client: it does not render server-side ABP MVC/Razor pages and it shares no .NET code with the backend. It talks to Cargonerds.HttpApi.Host purely over HTTP (REST + OData), SignalR, and Server-Sent Events (SSE), and authenticates against Cargonerds.AuthServer over OpenID Connect.

The legacy Blazor admin UI remains the internal/admin console; the public marketing site is Web.Public. For how the AppHost discovers and runs this app, see Aspire integration.

Workspace layout

frontend/ is an npm workspaces monorepo with exactly two members declared in frontend/package.json: realtime (this app) and ui-library (@cargonerds/ui-library, the shared component library). The realtime app consumes ui-library through a workspace symlink and compiles it in place — there is no separate build step for the library (see The ui-library).

Tech stack

Verified against frontend/realtime/package.json.

Concern What is used
Framework Next.js ^15.5.9 (App Router, output: 'standalone', Turbopack for dev and build)
Runtime React ^19.1.2 / React-DOM ^19.1.2
Language TypeScript ^5
Node 22.x (pinned in engines)
Styling Tailwind CSS v4 (@tailwindcss/postcss, @tailwindcss/typography, container-queries) + tailwind-merge
Server state TanStack Query v5 (@tanstack/react-query ^5.90.2, + devtools), TanStack Table
Auth OIDC via react-oidc-context ^3.3.0 / oidc-client-ts ^3.3.0 (Authorization Code + PKCE)
Realtime @microsoft/signalr ^10.0.0
Maps MapLibre GL (maplibre-gl ^5.10.0) + MapTiler
Forms / validation react-hook-form, Zod
i18n next-intl ^4.6.1 (messages sourced from ABP, not local JSON)
Charts Recharts ^3.7.0
OData odata-query ^8.0.5
Shared UI @cargonerds/ui-library (workspace package)
Tests Vitest
Lint/format ESLint 9, Prettier 3 (Allman braces, printWidth: 120, tabWidth: 4)

Project layout

frontend/realtime/src/
  app/            # Next.js App Router
    (main)/       # authenticated shell: shipments, orders, bookings,
                  #   quotations, insights, reports, inbox, search,
                  #   profile, settings, ...
    globals.css   # Tailwind v4 theme + brand tokens
    layout.tsx    # root layout (server) — builds EnvVars, mounts Providers + ProtectedRoute
    providers.tsx # client provider stack (Config → OIDC → Query → AppConfig → Localization)
  components/     # shared React components (ProtectedRoute, Header, Sidebar, ...)
  context/        # ConfigContext, AppConfigContext, LocalizationProvider,
                  #   GlobalSearchContext, StreamingExportContext
  hooks/          # useClient, useServices, useNotificationHub, useFeatures, useLocalization, ...
  services/       # ~25 typed API client factories (shipmentService, exportService, ...)
  types/          # hand-written DTO mirrors (apiShipment.ts, envVars.ts, ...)
  utils/  icons/

Runtime composition (provider stack)

The app is assembled in two layers. src/app/layout.tsx is a server component that resolves runtime configuration per request, and src/app/providers.tsx is the client provider tree.

flowchart TD
    L["layout.tsx (server)<br/>dynamic = 'force-dynamic'<br/>reads process.env → EnvVars"] --> P["Providers (client)"]
    P --> C["ConfigProvider<br/>(EnvVars carrier)"]
    C --> O["OIDCProvider<br/>react-oidc-context AuthProvider"]
    O --> Q["QueryClientProvider<br/>(TanStack Query, staleTime 60s)"]
    Q --> A["AppConfigProvider<br/>fetches preferences + application-configuration"]
    A --> S["CustomScriptInjector"]
    A --> Loc["LocalizationProvider<br/>NextIntlClientProvider"]
    Loc --> PR["ProtectedRoute → page"]

ProtectedRoute wraps the whole tree (it is mounted by layout.tsx inside <Providers>), so every page is auth-gated. Authenticated pages additionally render through the (main) route group's own layout, which mounts the shell (Sidebar/Header/Footer), the SignalR notification hub, and the global-search / streaming-export providers.

Configuration

The app is configured at runtime through a small set of environment variables, typed in src/types/envVars.ts:

src/types/envVars.ts
export interface EnvVars
{
    apiUrl: string;
    authUrl: string;
    appUrl: string;
    clientId: string;
    mapTilerKey: string;
}
TS field Env var Purpose
apiUrl API_URL Base URL of Cargonerds.HttpApi.Host
authUrl AuthServer__Authority OIDC authority (Cargonerds.AuthServer)
appUrl APP_URL The app's own public URL (OIDC redirect target)
clientId AUTH_CLIENT_ID OIDC client id (defaults to the Aspire resource name)
mapTilerKey MAPTILER_API_KEY MapTiler key for MapLibre maps

Runtime config injection (server → client)

Env vars are not baked at build time. layout.tsx is marked export const dynamic = 'force-dynamic', so it runs on the server on every request and reads process.env fresh, with a per-host fallback table keyed by AZURE_ENVIRONMENT:

src/app/layout.tsx
const azureUrls: { [id: string]: string } = {
    dev: 'dev.spark.cargonerds.dev',
    'dev.hub-prod': 'hub-prod-dev.spark.cargonerds.dev',
    'dev.hub-test': 'hub-test-dev.spark.cargonerds.dev',
    prod: 'rt3.rohlig.com',
    'prod.staging': 'staging-prod.spark.cargonerds.dev',
};

const envVars: EnvVars = {
    apiUrl:      process.env.API_URL               || 'https://api.'  + azureUrls[process.env.AZURE_ENVIRONMENT || ''],
    authUrl:     process.env.AuthServer__Authority || 'https://auth.' + azureUrls[process.env.AZURE_ENVIRONMENT || ''],
    appUrl:      process.env.APP_URL               || 'https://'      + azureUrls[process.env.AZURE_ENVIRONMENT || ''],
    clientId:    process.env.AUTH_CLIENT_ID        || 'realtime',
    mapTilerKey: process.env.MAPTILER_API_KEY      || 'jeQ6Cpq9MIEgQuWJOK5W',
};

The resolved EnvVars is passed as a prop into the client ConfigProvider (src/context/ConfigContext.tsx), so the rest of the app reads it via useConfig() and never touches process.env on the client.

.NET-style config key

AuthServer__Authority uses the ABP/.NET double-underscore configuration convention rather than a typical NEXT_PUBLIC_* name. This is intentional so the same key flows from Aspire/host configuration straight into the Next.js process.

How Aspire wires the env vars

In the AppHost, WithNextAuth(...) in src/Cargonerds.AppHost/Extensions/NodeAppExtensions.cs wires the live sibling endpoints and the MapTiler key onto the realtime resource:

src/Cargonerds.AppHost/Extensions/NodeAppExtensions.cs
return builder
    .WithSelfEnvironmentHttpsEndpoint("APP_URL")
    .WithEnvironmentHttpsEndpoint("API_URL", api)
    .WithEnvironmentHttpsEndpoint("AuthServer__Authority", authServer)
    .WithEnvironment("AUTH_CLIENT_ID", builder.Resource.Name)
    .WithEnvironment("MAPTILER_API_KEY", mapTilerApiKey);

mapTilerApiKey comes from WhiteLabelingOptions.ExternalApis.MapTiler.ApiKey, and AUTH_CLIENT_ID is simply the Aspire resource name (realtime). The full discovery/port/health-check wiring lives in DistributedAppBuilderExtensions.cs — see Running and Aspire integration.

Authentication (OIDC against the ABP AuthServer)

OIDCProvider (in providers.tsx) configures react-oidc-context against the ABP OpenIddict auth server using Authorization Code + PKCE (the oidc-client-ts default for response_type: 'code'):

src/app/providers.tsx
const oidcConfig: AuthProviderProps = {
    authority: authUrl,
    client_id: clientId,                 // 'realtime'
    redirect_uri: appUrl,
    post_logout_redirect_uri: appUrl,
    scope: 'openid profile email Cargonerds offline_access roles',
    response_type: 'code',
    response_mode: 'query',
    automaticSilentRenew: true,
    monitorSession: true,
    onSigninCallback: async () => { /* restore oidc_return_path, strip OIDC params */ },
};
  • The Cargonerds scope is the ABP API resource scope; offline_access enables refresh tokens, and automaticSilentRenew keeps the access token fresh in the background.
  • Login redirect: ProtectedRoute (src/components/ProtectedRoute.tsx) calls auth.signinRedirect() whenever auth has finished loading and the user is not authenticated. Before redirecting it stashes the current path in sessionStorage['oidc_return_path'].
  • Return-path sanitization: onSigninCallback reads that path back and strips a fixed allow-list of transient OIDC params before replacing the URL, so the user never lands on a callback URL with stale auth parameters:
src/app/providers.tsx
const OIDC_RESPONSE_PARAMS = [
    'code', 'state', 'session_state', 'iss',
    'error', 'error_description', 'culture', 'ui-culture',
];

Param allow-list is manual

If the backend ever adds a new transient callback parameter, it must be added to OIDC_RESPONSE_PARAMS or it will linger in the restored URL.

See Authentication for the server-side OpenIddict configuration.

Token injection — useClient

src/hooks/useClient.ts is the single HTTP client. useClient() returns { fetch, rawFetch } and:

  • prefixes relative URLs with apiUrl (absolute http(s)://… URLs pass through unchanged);
  • attaches Authorization: Bearer <access_token> from useAuth() and a JSON Content-Type (unless the body is FormData);
  • fetch parses JSON and returns { data, error, response }; rawFetch returns the raw Response (used for SSE/streaming).
src/hooks/useClient.ts
headers.set('Authorization', `Bearer ${auth.user?.access_token}`);
headers.set('Accept-Language', 'en-US');

Accept-Language is hard-coded to en-US

Normal API calls through useClient always send Accept-Language: en-US. Only the application-configuration request (see below) sends the user's chosen language. So most API payloads come back in en-US regardless of UI language; only localized config/labels follow the user's culture.

Dynamic claims refresh

On mount, ProtectedRoute fires a React Query that calls dynamicClaimsService.refreshDynamicClaims()POST /api/account/dynamic-claims/refresh (src/services/dynamicClaimsService.ts). This warms ABP's dynamic claims cache, which can be cold after a server cold-start.

Route-level authorization

ProtectedRoute is reusable per route, not just at the root. Beyond the global auth gate it supports:

Prop Effect
requiresGrant Require an ABP permission (checked against grantedPolicies)
requiresAdminUser Require an admin user (permission or role claim, via isAdminUser)
requiredOrganizationUnitId Require a specific org unit to be the active organization
requiredOrganizationUnitName Require access to a named org unit
requiredOrganizationNameIncludes Require the active org name to contain a substring
redirectOnFailureTo / fallback Redirect, or render a fallback "Access restricted" panel

For example, the Rockline insights sidebar link in (main)/layout.tsx is shown only when isGranted('Hub.Insights.MyInsights.View') and the selected organization name contains rockline.

App configuration & permissions (ABP application-configuration)

AppConfigProvider (src/context/AppConfigContext.tsx) is the client-side mirror of ABP's configuration. After authentication it fetches, in order:

  1. GET /api/app/profile/preferencesProfilePreferences (language, timezone, measurementSystem, dateFormat, currency).
  2. GET /api/abp/application-configuration — sending Accept-Language = the user's preferred language — → ApplicationConfiguration, which carries setting.values, features.values, auth.grantedPolicies, localization (current culture + the Realtime/CodeEntities localization resources) and extraProperties (AppVersion, Environment, BuildDate, WhiteLabeling.footerLinks).
src/context/AppConfigContext.tsx
const { data: config } = useQuery({
    queryKey: ['config'],
    queryFn: async ({ signal }) =>
    {
        const { data } = await client.fetch<ApplicationConfiguration>('/api/abp/application-configuration', {
            headers: { 'Accept-Language': settings?.language ?? '' },
            signal,
        });
        return data;
    },
    enabled: auth.isAuthenticated && !!settings,
});

Permissions, features and settings

  • Permissions: isGranted(permission) returns config.auth.grantedPolicies[permission] === true. The RealtimePermissionNames union in AppConfigContext.tsx keeps permission strings type-safe (e.g. Hub.Shipment.View, Pricing.Quotation.Add, Hub.RealtimeAdmin, Hub.Insights.MyInsights.View). These mirror backend HubPermissions/PricingPermissions — see Permissions and ABP's authorization & permission management.
  • Features & settings: useFeatures() (src/hooks/useFeatures.ts) reads features.values and setting.values, comparing against the string 'true'. A HubSettings constant block mirrors modules/hub/src/Hub.Domain/Settings/HubSettings.cs (e.g. Hub.CustomScript.Enabled, Hub.Commodity.FilteredView). See ABP features and settings.

Keep mirrors in sync

RealtimePermissionNames and HubSettings are hand-maintained mirrors of C# identifiers. There is no codegen — they must be updated when the backend permission/setting names change.

Localization (next-intl driven by ABP)

There is no local messages JSON and no i18n routing/middleware. LocalizationProvider (src/context/LocalizationProvider.tsx) feeds NextIntlClientProvider with locale = config.localization.currentCulture.culture and messages built from the ABP localization.values maps (Realtime + CodeEntities). Because next-intl rejects dots in message keys, keys containing . are filtered out:

src/context/LocalizationProvider.tsx
const messages = {
    ...Object.fromEntries(Object.entries(values?.Realtime ?? {}).filter(([k]) => !k.includes('.'))),
    CodeEntities: Object.fromEntries(Object.entries(values?.CodeEntities ?? {}).filter(([k]) => !k.includes('.'))),
};

Dotted keys are silently dropped

Any ABP localization key that contains a . is unavailable to t() on the client (except the CodeEntities namespace, which is nested deliberately). Date/time formatting is handled separately by useLocalization() (src/hooks/useLocalization.ts) using date-fns + @date-fns/tz. See Localization for details.

Data access (REST + OData)

All requests go through useClient().fetch. Components never call services directly; instead src/hooks/useServices.ts instantiates ~25 service factories with the shared client and exposes them through a single useServices() hook:

src/hooks/useServices.ts (excerpt)
export const useServices = () =>
{
    const client = useClient();
    const { mapTilerKey, authUrl } = useConfig();
    return {
        shipmentService: shipmentService(client),
        orderService: orderService(client),
        exportService: exportService(client),
        // ... ~25 services
    };
};

Each service is a plain function service(client) => ({ ...methods }) under src/services/.

No generated API client

There is no @abp/ng.* proxy and no NSwag/OpenAPI codegen. Every request/response type under src/types/api*.ts is a hand-written mirror of a backend DTO and can drift from the backend. Note that ABP serializes enums as integers by default, so the TS types use numeric enums to match. See API clients.

Two consumption styles

The app calls the backend in two parallel styles, both through the same client:

Style Shape Examples
ABP auto-API REST /api/app/<service>/<method> (+ ABP framework endpoints) /api/app/shipment, /api/app/profile/preferences, /api/abp/application-configuration, /api/account/dynamic-claims/refresh
OData /odata/<EntitySet> with $filter/$expand/$select/$orderby/$top/$skip/$count/$apply /odata/Shipment, /odata/CalculatedShipment, /odata/Invoice, /odata/ShipmentBaseDocument

REST path/query shapes follow ABP's auto API controller conventions (e.g. the first Guid argument becomes a path segment).

shipmentService.ts shows both styles side by side — a legacy REST list endpoint kept for facets, plus OData queries built with odata-query's buildQuery:

src/services/shipmentService.ts (excerpt)
const qCore = buildQuery({ filter: filterObject, search, orderBy, select, expand: expandObj, skip, top, count });
let url = `/odata/Shipment${qCore}`;

const options: RequestInit = {
    ...(opts?.withFacets && { headers: { Prefer: 'odata.include-annotations="*"' } }),
};

const { data } = await client.fetch<{
    value: ApiShipment[];
    ['@odata.count']: number;
    ['@cn.facets']: DynamicFilter[];
}>(url, options);

OData responses are read as { value, '@odata.count', '@cn.facets' }. @cn.facets is a custom backend annotation that returns filter facets, and it is only emitted when the request sends the Prefer: odata.include-annotations="*" header (withFacets: true). Aggregations use raw $apply=... (groupby/aggregate), e.g. in orderService and rocklineDashboardService. See OData & filtering and the general REST API page.

Realtime transports

SignalR notifications

src/hooks/useNotificationHub.ts is mounted once, in (main)/layout.tsx. It connects to ABP's notification hub and, on every server ReceiveNotification push, invalidates the inbox and unread-count React Query caches — so the inbox list and the unread badge refresh without polling (the same invalidation also runs on onreconnected).

src/hooks/useNotificationHub.ts (excerpt)
// ABP convention: /signalr-hubs/{hub-name}
const hubUrl = `${apiUrl}/signalr-hubs/notification`;

const connection = new HubConnectionBuilder()
    .withUrl(hubUrl, {
        accessTokenFactory: () => tokenRef.current,
        transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
    })
    .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
    .configureLogging(LogLevel.None)
    .build();

The hub URL follows ABP's /signalr-hubs/{hub-name} convention, and the connection authenticates via accessTokenFactory (reading a ref-held token so a renewed token is picked up).

Streaming Excel export (SSE)

exportService.streamExport (src/services/exportService.ts) POSTs to /api/app/excel-export/stream-export with Accept: text/event-stream (via rawFetch), then readSseStream (src/services/sseClient.ts) parses newline-delimited JSON events. The event protocol is a discriminated union:

src/services/exportService.ts
export type ExportStreamEvent =
    | { type: 'progress'; phase: string; percent: number; done?: number; total?: number }
    | { type: 'ready'; fileName: string; downloadUrl: string }
    | { type: 'error'; message: string };
  • progress drives a percent UI;
  • ready triggers a native browser download against downloadUrl — the file bytes never pass through JavaScript memory;
  • error carries a server-side failure message.

StreamingExportProvider lives at the (main) layout level so an in-flight export survives client-side navigation. Per the service/provider comments, if the caller aborts (e.g. the tab is closed) the server falls back to emailing the finished file. The reader drops malformed JSON lines silently rather than tearing down the stream, and is abort-safe.

The same exportService also exposes REST CRUD for scheduled export jobs (/api/app/excel-export/queue-export, /scheduled-jobs, /{jobId}/scheduled-job, etc.).

The ui-library

frontend/ui-library (@cargonerds/ui-library) is a source-only, un-built shared React component library. Its package.json points main/types/exports straight at ./index.ts:

frontend/ui-library/package.json (excerpt)
{
    "name": "@cargonerds/ui-library",
    "main": "./index.ts",
    "types": "./index.ts",
    "exports": { ".": "./index.ts" },
    "sideEffects": ["**/*.css"],
    "peerDependencies": { "next": "^15.5.9", "react": "^19.1.2", "react-dom": "^19.1.2", "tailwind-merge": "^3.0.0" }
}

index.ts re-exports ~90 components and types — primitives and composite widgets built on Radix UI + Tailwind: Button, Modal, Tabs, Select/MultiSelect/SearchableSelect, InfiniteScrollTable/ReactTable, FilterBar/DynamicFilterButton, Calendar, chart tiles, MapTiles, Editor, and more.

How it is consumed and built:

  • realtime depends on it as "@cargonerds/ui-library": "*", resolved to the workspace via the node_modules symlink.
  • next.config.ts lists it in transpilePackages, so Next.js compiles the library's raw TS/JSX in place:
frontend/realtime/next.config.ts
const nextConfig: NextConfig = {
    output: 'standalone',
    transpilePackages: ['@cargonerds/ui-library'],
    turbopack: { rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js' } } },
    async rewrites() { return [{ source: '/brokerage/:id', destination: '/shipments/:id' }]; },
};
  • SVG icons are turned into React components by the @svgr/webpack Turbopack rule. The icon-name union is generated by npm run generate:icon-names (in the ui-library workspace), which scans icons/*.svg into a typed IconNames union.
  • Styling uses Tailwind utility classes merged with tailwind-merge (twMerge); brand tokens such as bg-brand-100 / text-grey-125 are defined in the app's globals.css Tailwind v4 theme — see Theming.

Consumers must transpile the library

Because the library ships raw TypeScript with no build, any consumer must list it in transpilePackages and satisfy its peerDependencies (React 19, Next 15, tailwind-merge).

Running

AddAllFrontends()AddAllNpmAppsInPath(CargonerdsConsts.Aspire.Directories.Frontends) in src/Cargonerds.AppHost/Extensions/DistributedAppBuilderExtensions.cs auto-discovers every folder under frontend/ and regenerates frontend/package.json (CreatePackageJson) so the workspaces array always reflects the real subfolders (respecting .dockerignore). This is why frontend/package.json is a minimal generated file. Each app directory that contains a Dockerfile is registered with AddJavaScriptApp(appName, app, startScript).

src/Cargonerds.AppHost/Extensions/DistributedAppBuilderExtensions.cs (excerpt)
string startScript = scriptNames?.FirstOrDefault(n => n.Contains("aspire") || n.Contains("start")) ?? "start";

var nodeApp = builder
    .AddJavaScriptApp(appName, app, startScript)
    .WithEnvironment("BROWSER", "none")
    .WithHttpEndpoint(port: fixedPort, env: "PORT", name: "http")
    .WithExternalHttpEndpoints()
    .WithHttpHealthCheck("/", 200)
    .WithOtlpExporter()
    .PublishAsDockerFile(/* ... */)
    .IfRunMode(b => b.WithEnvironment("NODE_TLS_REJECT_UNAUTHORIZED", "0"));

Concretely for realtime:

  • the start script is chosen as the first script whose name contains aspire or start, so it runs aspire-dev (npm i --force && next dev --turbopack --hostname 0.0.0.0);
  • in run mode it gets a fixed port from RunModeServicePorts.RealtimeHttpPort = 4200, plus BROWSER=none, an OTLP exporter, a / health check (200), external HTTP endpoints, and NODE_TLS_REJECT_UNAUTHORIZED=0 (matching .env.example);
http://localhost:4200
  • in publish mode, because realtime's dependencies include the ui-library package name, it is published via its Dockerfile with the frontend workspace root as the Docker build context — so the whole monorepo (including the un-built ui-library) is available during the image build.

See Aspire integration for the full picture and ABP's .NET Aspire integration.

aspire-dev runs npm i --force on every start

Aspire reinstalls dependencies on each launch, so the first run can be slow and can mask lockfile issues.

Standalone

cd frontend/realtime
npm install
npm run dev          # next dev --turbopack -p 4200

Other scripts (package.json):

npm run build        # next build --turbopack
npm run start        # next start
npm run test         # vitest run
npm run lint         # eslint .
npm run type:check   # tsc --pretty --noEmit --project tsconfig.json
npm run prettier:fix # prettier --write .

Deployment

The app ships a multi-stage frontend/realtime/Dockerfile (depsbuilderrunner, Node 22 Alpine). It installs the whole workspace with npm ci, builds only the realtime workspace, and runs the Next.js standalone server:

frontend/realtime/Dockerfile (excerpt)
ARG NODE_VERSION=22
# ... deps: npm ci over the whole frontend workspace ...
RUN npm run build --workspace=realtime          # builder stage
# ... runner stage ...
ENV PORT=8080
EXPOSE 8080
CMD ["node", "realtime/server.js"]

It is deployed as a container alongside the backend services — see Deployment overview, Azure Container Apps and the env-var reference in Deployment configuration.

Theming

Brand colors and the Tailwind v4 theme are defined in src/app/globals.css, and the default branding is Röhlig (page title, RohligLogo.svg, Usercentrics consent ID zO90Ac4bE). White-labeling is data-driven via extraProperties.WhiteLabeling from the application-configuration response. See Theming.

Gotchas

Things to watch out for

  • No generated API client. Types under src/types/api*.ts are hand-written and can drift; ABP serializes enums as integers, so the TS types must use numeric enums.
  • Accept-Language is hard-coded to en-US in useClient for normal API calls, while the application-configuration request uses the user's actual language.
  • tsconfig.json lists a non-existent src/middleware.ts in include. There is no Next middleware and no localized routing; i18n is entirely client-side via next-intl fed from ABP config.
  • Two ESLint configs coexist — the flat eslint.config.mjs (minimal) and a legacy .eslintrc.json (the richer rule set, incl. react-compiler/react-compiler: error, simple-import-sort, no-console).
  • next-intl drops keys containing . (see Localization).
  • @cn.facets + Prefer: odata.include-annotations="*" are custom backend conventions; OData list endpoints return facets only when that header is sent (withFacets: true).
  • OIDC return-path sanitization is an explicit param allow-list (OIDC_RESPONSE_PARAMS); new transient params must be added there.
  • mapTilerKey has a committed default in layout.tsx ('jeQ6Cpq9MIEgQuWJOK5W') used when MAPTILER_API_KEY is unset.
  • The ui-library ships raw TypeScript — consumers must transpilePackages it and match its peer deps.