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:
| 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) |
EnvVars is intentionally limited to connections — the live sibling endpoints the app needs to
reach the backend, which only Aspire (or an equivalent deployment pipeline) can resolve. Everything
the app can decide for itself is not in EnvVars and never injected: the OIDC client_id is a
constant owned by the app (see Authentication
below), and non-URI config like the MapTiler key is fetched from the backend's
application-configuration endpoint at runtime (see
Non-URI config: useAppConfig below). This split keeps realtime
able to run standalone (e.g. against a fixed dev backend) without Aspire orchestrating it.
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:
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 || ''],
};
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 only the live sibling connection
endpoints onto the realtime resource:
return builder
.WithSelfEnvironmentHttpsEndpoint("APP_URL")
.WithEnvironmentHttpsEndpoint("API_URL", api)
.WithEnvironmentHttpsEndpoint("AuthServer__Authority", authServer);
Aspire deliberately injects nothing else — it has no opinion on the OIDC client id, which the app
defines itself (see below). The full discovery/port/health-check wiring lives in
DistributedAppBuilderExtensions.cs — see Running and
Aspire integration.
Non-URI config: useAppConfig¶
Everything that isn't a backend URI/id is served through the same mechanism the Blazor admin frontend
uses: WhiteLabelingOptions properties are explicitly opted in to client visibility with
[PublicConfiguration] (Hub.Domain.Shared/WhiteLabeling/PublicConfigurationAttribute.cs), and
CargonerdsAppConfigContributor (Cargonerds.HttpApi.Host) calls
PublicConfigurationAttribute.ToPublicConfiguration(whitelabel) before publishing the result under
extraProperties.WhiteLabeling on GET /api/abp/application-configuration. AppConfigProvider
(src/context/AppConfigContext.tsx) already fetches that endpoint once the user is authenticated.
ToPublicConfiguration rebuilds the object from scratch out of only the [PublicConfiguration]-marked
properties (recursing into nested marked types/collections, e.g. ExternalApis → MapTiler); anything
unmarked — EntraLogins, ExternalApis.Google/Algolia/PricingManager/Eadapter — is omitted from the
JSON entirely, not just nulled out. This matters: nulling a property still serializes its key,
which would tell a client (or an attacker) exactly which internal integrations exist even without
leaking their values. A getter-only property that isn't marked [PublicConfiguration] is dropped the
same as any other unmarked property, since the filter now runs before serialization instead of nulling
values on the live object.
WhiteLabelingConfig (src/types/whiteLabelingConfig.ts) mirrors every [PublicConfiguration]
property the backend can actually send, not just the ones the app currently reads, so new consumers
don't have to widen the type first:
export interface WhiteLabelingConfig
{
appName: string;
companyName: string;
companyNameShort: string;
logo: string;
appLogo: string;
backgroundImage: string;
backgroundVideo: string;
favicon: string;
disableCustomUserName: boolean;
additionalJavaScriptFiles: string[];
additionalStyleSheetFiles: string[];
copyrightLinks: FooterLink[];
footerLinks: FooterLink[];
externalApis?: {
mapTiler?: {
apiKey?: string;
};
};
}
useAppConfig().config.extraProperties.WhiteLabeling.externalApis?.mapTiler?.apiKey is how
useServices.ts, Map.tsx and MapDetails.tsx read the MapTiler key today — there is no
MAPTILER_API_KEY env var and no hardcoded fallback key anymore. Adding a new client-visible config
value means adding [PublicConfiguration] to the backend property and a field to
WhiteLabelingConfig; no frontend env-var plumbing is needed.
externalApis only models mapTiler
ExternalApis on the backend also has Google, Algolia, PricingManager and Eadapter
properties, but none of them carry [PublicConfiguration], so they never appear in the response —
they're omitted from WhiteLabelingConfig because the key itself is never sent.
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'):
const AUTH_CLIENT_ID = 'realtime';
const oidcConfig: AuthProviderProps = {
authority: authUrl,
client_id: AUTH_CLIENT_ID,
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 */ },
};
AUTH_CLIENT_IDis a constant owned by the app, not injected by Aspire — it must match theClientIdregistered for this app in the AuthServer's OpenIddict seed data (see Authentication § Seeded clients). This is the same pattern the Blazor admin frontend uses (its OIDC client id is hardcoded in its ownappsettings.json, not pushed in by the AppHost).- The
Cargonerdsscope is the ABP API resource scope;offline_accessenables refresh tokens, andautomaticSilentRenewkeeps the access token fresh in the background. - Login redirect:
ProtectedRoute(src/components/ProtectedRoute.tsx) callsauth.signinRedirect()whenever auth has finished loading and the user is not authenticated. Before redirecting it stashes the current path insessionStorage['oidc_return_path']. - Return-path sanitization:
onSigninCallbackreads 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:
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(absolutehttp(s)://…URLs pass through unchanged); - attaches
Authorization: Bearer <access_token>fromuseAuth()and a JSONContent-Type(unless the body isFormData); fetchparses JSON and returns{ data, error, response };rawFetchreturns the rawResponse(used for SSE/streaming).
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:
GET /api/app/profile/preferences→ProfilePreferences(language, timezone, measurementSystem, dateFormat, currency).GET /api/abp/application-configuration— sendingAccept-Language= the user's preferred language — →ApplicationConfiguration, which carriessetting.values,features.values,auth.grantedPolicies,localization(current culture + theRealtime/CodeEntitieslocalization resources) andextraProperties(AppVersion,Environment,BuildDate,WhiteLabeling.footerLinks).
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)returnsconfig.auth.grantedPolicies[permission] === true. TheRealtimePermissionNamesunion insrc/types/realtimePermissions.tskeeps permission strings type-safe (e.g.Hub.Shipment.View,Pricing.Quotation.Add,Hub.RealtimeAdmin,Hub.Insights.MyInsights.View). These mirror backendHubPermissions/PricingPermissions— see Permissions and ABP's authorization & permission management. - Features & settings:
useFeatures()(src/hooks/useFeatures.ts) readsfeatures.valuesandsetting.values, comparing against the string'true'. AHubSettingsconstant block mirrorsmodules/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:
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:
export const useServices = () =>
{
const client = useClient();
const { authUrl } = useConfig();
const { config } = useAppConfig();
const mapTilerKey = config.extraProperties.WhiteLabeling.externalApis?.mapTiler?.apiKey ?? '';
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:
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).
// 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:
export type ExportStreamEvent =
| { type: 'progress'; phase: string; percent: number; done?: number; total?: number }
| { type: 'ready'; fileName: string; downloadUrl: string }
| { type: 'error'; message: string };
progressdrives a percent UI;readytriggers a native browser download againstdownloadUrl— the file bytes never pass through JavaScript memory;errorcarries 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:
{
"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:
realtimedepends on it as"@cargonerds/ui-library": "*", resolved to the workspace via thenode_modulessymlink.next.config.tslists it intranspilePackages, so Next.js compiles the library's raw TS/JSX in place:
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/webpackTurbopack rule. The icon-name union is generated bynpm run generate:icon-names(in the ui-library workspace), which scansicons/*.svginto a typedIconNamesunion. - Styling uses Tailwind utility classes merged with
tailwind-merge(twMerge); brand tokens such asbg-brand-100/text-grey-125are defined in the app'sglobals.cssTailwind 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¶
Via Aspire (recommended)¶
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).
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
aspireorstart, so it runsaspire-dev(npm i --force && next dev --turbopack --hostname 0.0.0.0); - in run mode it gets a fixed port from
RunModeServicePorts.RealtimeHttpPort = 4200, plusBROWSER=none, an OTLP exporter, a/health check (200), external HTTP endpoints, andNODE_TLS_REJECT_UNAUTHORIZED=0(matching.env.example);
- in publish mode, because
realtime'sdependenciesinclude the ui-library package name, it is published via itsDockerfilewith 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¶
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 (deps → builder → runner, Node 22
Alpine). It installs the whole workspace with npm ci, builds only the realtime workspace, and
runs the Next.js standalone server:
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*.tsare hand-written and can drift; ABP serializes enums as integers, so the TS types must use numeric enums. Accept-Languageis hard-coded toen-USinuseClientfor normal API calls, while the application-configuration request uses the user's actual language.tsconfig.jsonlists a non-existentsrc/middleware.tsininclude. 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. - The MapTiler key requires authentication to load, since it comes from
useAppConfig()/GET /api/abp/application-configuration, which only fetches onceauth.isAuthenticated. There is no pre-auth fallback key. - The ui-library ships raw TypeScript — consumers must
transpilePackagesit and match its peer deps.