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;
}
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:

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 || ''],
};

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:

src/Cargonerds.AppHost/Extensions/NodeAppExtensions.cs
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. ExternalApisMapTiler); 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:

src/types/whiteLabelingConfig.ts
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'):

src/app/providers.tsx
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_ID is a constant owned by the app, not injected by Aspire — it must match the ClientId registered 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 own appsettings.json, not pushed in by the AppHost).
  • 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 src/types/realtimePermissions.ts 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 { 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:

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.
  • The MapTiler key requires authentication to load, since it comes from useAppConfig()/GET /api/abp/application-configuration, which only fetches once auth.isAuthenticated. There is no pre-auth fallback key.
  • The ui-library ships raw TypeScript — consumers must transpilePackages it and match its peer deps.