Modules (NXM)
Customer portal
A package can publish some of its records on the web, for the customers of the firm that installed it. How the portal section of the manifest works, what nexxus checks, and what a module service answers.
Some packages serve people who never sign in: the guests of a holiday-home agency look for a house, check the dates and send a booking request. The customer portal is that public side. The package declares in its manifest which records and fields are public and which pages show them; a separate portal app draws the pages on the firm’s own web address. The package brings no web server and no code of its own for this. The only code is in its module service, for what has to be computed: free dates, a price, a booking request.
How it fits together
Section titled “How it fits together”Portal domain
The firm's own address, e.g. stay.agency.example, set up as a portal domain of the tenant.
Portal app
Renders the pages the package declares, on the server, so search engines read them.
Public API of nexxus
Hands out only the public fields of the public records, for the tenant of that address.
Module service
Answers what has to be computed: availability, a price, a booking request.
Portal domains
Section titled “Portal domains”A tenant’s domain has a role. Its own domains (Own domains) carry the app and its sign-in. Its portal domains (Portal domains) carry only the portal: on such an address nobody can sign in, and the administration, its API and its live channel answer 404 as if they did not exist. A superadmin sets both under AdministrationCompanyTenants. Links that nexxus writes for staff always point to the own domains.
Each answer of the portal API belongs to the tenant of the address it was called on. A page on stay.agency.example cannot read another tenant’s records.
One package with a portal per tenant: if a tenant installs two, the one installed first is the site.
The portal section of the manifest
Section titled “The portal section of the manifest”The example is the holiday-home package: guests browse listings, open one and send a request.
"portal": { "name": { "en": "Stays", "de": "Unterkünfte" }, "siteNameSetting": "company_name", "public": { "listing": { "filter": { "status": "active" }, "fields": ["name", "kind", "region", "city", "description", "bedrooms", "max_guests", "amenities", "base_price", "photos", "location"], "geo": { "field": "location", "precision": "approximate" } } }, "pages": [ { "key": "stays", "type": "catalog", "entity": "listing", "path": "", "title": { "en": "Holiday homes", "de": "Ferienhäuser" }, "card": { "titleField": "name", "subtitleFields": ["city", "region"], "imageField": "photos", "facts": ["bedrooms", "max_guests"], "priceField": "base_price", "priceSuffix": { "en": "per night", "de": "pro Nacht" } }, "filters": [ { "field": "region" }, { "field": "kind" }, { "field": "max_guests", "op": "gte", "label": { "en": "Guests", "de": "Gäste" } } ], "dates": { "quote": "quote" }, "map": true, "detail": "stay" }, { "key": "stay", "type": "detail", "entity": "listing", "path": "stays/:slug", "slugField": "name", "title": { "en": "Stay", "de": "Unterkunft" }, "sections": [ { "type": "gallery", "field": "photos" }, { "type": "facts", "fields": ["kind", "bedrooms", "max_guests"] }, { "type": "text", "field": "description" }, { "type": "chips", "field": "amenities" }, { "type": "map", "field": "location" }, { "type": "availability", "endpoint": "availability" }, { "type": "booking", "quote": "quote", "request": "booking_request" } ] } ], "endpoints": [ { "key": "availability", "method": "GET", "service": "concierge", "params": ["record", "from", "to"] }, { "key": "quote", "method": "GET", "service": "concierge", "params": ["record", "check_in", "check_out", "guests"] }, { "key": "booking_request", "method": "POST", "service": "concierge", "params": ["record"], "form": [ { "key": "name", "type": "string", "required": true }, { "key": "email", "type": "email", "required": true }, { "key": "guests", "type": "number", "required": true }, { "key": "check_in", "type": "date", "required": true }, { "key": "check_out", "type": "date", "required": true }, { "key": "message", "type": "text" }, { "key": "consent", "type": "consent", "required": true, "label": { "en": "I agree that my details are used to answer my request.", "de": "Ich bin einverstanden, dass meine Angaben zur Beantwortung meiner Anfrage verwendet werden." } } ], "rateLimit": { "perIpPerHour": 10 } } ]}The pages around a listing come from the same package. The detail page gets three more sections, and four pages join it: one to message the host, a contact page and two legal pages. Shortened from higgins.rentals 1.2.1, whose listings also publish house_rules, checkin_time, checkout_time, min_nights and cleaning_fee for the FAQ:
"portal": { "public": { // Settings the site may show: footer, contact card, host and legal pages "settings": ["company_name", "company_address", "portal_phone", "portal_email", "portal_hours", "portal_host_intro", "portal_hosting_since", "agency_languages", "cancellation_policy", "privacy_text", "legal_text"], "listing": { /* as above */ }, // The reviews: a second public entity, with a display name, never the guest's name "review": { "fields": ["display_name", "rating", "received_at", "text"] } }, "pages": [ { "key": "stay", "type": "detail", /* as above */ "sections": [ /* the sections above, with amenities instead of chips */ { "type": "amenities", "field": "amenities" }, { "type": "reviews", "entity": "review", "via": "listing", "fields": ["display_name", "rating", "received_at", "text"], "nameField": "display_name", "dateField": "received_at", "ratingField": "rating", "textField": "text" }, { "type": "host", "setting": "portal_host_intro", "fields": ["portal_hosting_since", "agency_languages"], "page": "inquiry" } ] }, { "key": "inquiry", "type": "inquiry", "entity": "listing", "path": "stays/:slug/contact", "title": { "en": "Message the host", "de": "Gastgeber kontaktieren" }, "sections": [ { "type": "faq", "amenities": "amenities", "groups": [ { "title": { "en": "Arrival and departure", "de": "Anreise und Abreise" }, "items": [ { "field": "checkin_time" }, { "field": "checkout_time" }, { "amenity": "parking", "text": { "en": "Parking at the house", "de": "Parkplatz am Haus" } } ] }, { "title": { "en": "Price and availability", "de": "Preis und Verfügbarkeit" }, "items": [ { "field": "min_nights" }, { "field": "cleaning_fee" }, { "setting": "cancellation_policy" } ] } ] }, { "type": "form", "endpoint": "contact" } ] }, { "key": "contact", "type": "contact", "path": "contact", "nav": true, "title": { "en": "Contact", "de": "Kontakt" }, "sections": [ { "type": "contact", "fields": ["company_address", "portal_phone", "portal_email", "portal_hours"] }, { "type": "form", "endpoint": "contact" } ] }, { "key": "privacy", "type": "info", "path": "privacy", "footer": true, "title": { "en": "Privacy notice", "de": "Datenschutz" }, "setting": "privacy_text" }, { "key": "legal", "type": "info", "path": "legal", "footer": true, "title": { "en": "Legal notice", "de": "Impressum" }, "setting": "legal_text" } ], "endpoints": [ // For both forms; only the inquiry page sends a listing as record { "key": "contact", "method": "POST", "service": "concierge", "params": ["record"], "form": [ { "key": "name", "type": "string", "required": true }, { "key": "email", "type": "email", "required": true }, { "key": "message", "type": "text", "required": true, "maxLength": 2000 }, { "key": "consent", "type": "consent", "required": true, "label": { "en": "I agree that my details are used to answer my message.", "de": "Ich bin einverstanden, dass meine Angaben zur Beantwortung meiner Nachricht verwendet werden." } } ], "rateLimit": { "perIpPerHour": 10 } } ]}What is public
Section titled “What is public”public.<entity> is the only data the portal ever gets from that entity.
| Key | Meaning |
|---|---|
filter | Field and value (or a list of values) every public record has, e.g. only active listings. The fields must be indexed. A visitor’s filter never lifts it |
fields | The fields the portal receives, nothing else. Allowed are string, text, number, boolean, date, datetime, enum, geo and a ref to core:dmsDocument (images). A reference to a contact or a user, and json, are refused |
geo | field and precision: approximate (the default for every public geo field) rounds the coordinates on the server to two decimals, about 1 km; exact passes them unchanged |
public.settings is a list of installation settings the site may show, such as the phone number, the address or the privacy notice. Only settings of type string, text or enum are allowed; every other setting of the installation stays private.
A ref to core:dmsDocument in fields makes those documents public images. The portal gets them as { "id", "url", "alt" }, with alt from the document’s description. The address is signed and bound to tenant, record and document; nexxus serves the image only while a public field of a public record still names it, and only JPEG, PNG, WebP, GIF or AVIF. With ?w=400, 800, 1200 or 1600 it serves the image scaled down to that width, for srcset on phones.
| Key | Meaning |
|---|---|
key, title | Name of the page and its heading |
type | catalog (a list with cards, filters and an optional map), detail (one record), inquiry (a question about one record, with the slug of the detail page), contact (the agency’s contact page, no record) or info (a text page such as the privacy notice, no record) |
entity | An entity of public; not on contact and info pages |
path | Below the site’s root, unique; "" is the start page. A detail or inquiry page contains :slug exactly once, the other types none |
nav, footer | true puts a link to the page into the header or the footer |
setting | info page: a public string or text setting, shown as paragraphs (a blank line starts a new one) |
slugField | Detail page: a public string field the address is made of (Ocean View Villa becomes ocean-view-villa; a second record of the same name gets its id appended) |
card | Catalog: titleField, subtitleFields, imageField (images; without it the card is text only), facts, priceField (a number), priceSuffix. Without priceField and dates.quote the card has no price line |
filters | Catalog: public indexed fields; op eq (default), gte or lte (numbers and dates). Enum fields become chips; text fields with values to offer, a gte number (guests) and dates go into the search bar, which is left out when it has none of them |
dates.quote | Catalog: a GET endpoint that prices a stay once dates are set |
map, detail | Catalog: show a map (needs a public geo field); the key of the detail page. A marker carries the price of card.priceField when that field has a currency, and the quoted total once dates are set and the stay is free. A detail page leads back to the catalog of its own entity |
sections | detail and inquiry pages: gallery (images), facts, text, chips, amenities (an enum field, shown with icons), map (geo), availability (a GET endpoint), booking (quote: GET, request: POST), reviews, host, faq. Only on detail pages: slots (endpoint: GET, request: POST). Any page: contact (public settings in fields) and form (a POST endpoint’s form inline) |
Every field and setting a page names must be public.
The newer sections in more detail:
| Section | Keys |
|---|---|
amenities | field: a public enum field. A value of it may carry icon, a short key such as wifi from the portal’s icon set; without one the portal shows a neutral check mark |
reviews | entity (another public entity), via (its reference to the page’s entity), fields (public fields of that entity), and which of them is the name, date, rating and text: nameField, dateField, ratingField, textField. The portal shows the newest first, with the average rating. Publish a display name such as “Anna K.”, never a guest’s full name or e-mail address |
host | setting (an introduction text), fields (more public settings, such as “hosting since”), page (the inquiry page that “Message the host” leads to; without it the button is left out), endpoint (accepted, but the portal app does not use it: the inquiry page brings its own form) |
faq | groups, each with a title and items. An item is one of {"field": ...} (a public field of the record), {"setting": ...} (a public setting) or {"amenity": ..., "text": {...}} (a text shown when the record has that value of the enum field the section names in amenities); every item may carry its own label |
slots | Free appointment times instead of a range of nights. endpoint: a GET endpoint with the params record, from and to, which lists the times. request: a POST endpoint with the params record and the slotParam, whose form the visitor fills in for the chosen time. slotParam: the name the chosen time is sent under, never record, lang or website. days: how far ahead a visitor can look, 7 to 90 days, 28 by default. title replaces the heading Choose a time |
A practice whose patients ask for an appointment declares slots on the treatment page. The package declares "platform": ">=0.13.0 <1.0.0", the first platform version with this section:
"pages": [ { "key": "treatment", "type": "detail", "entity": "treatment", "path": "treatments/:slug", "slugField": "name", "title": { "en": "Treatment", "de": "Behandlung" }, "sections": [ { "type": "gallery", "field": "photos" }, { "type": "text", "field": "description" }, { "type": "slots", "endpoint": "slots", "request": "appointment", "slotParam": "slot", "days": 28, "title": { "en": "Choose a time", "de": "Termin wählen" } } ] }],"endpoints": [ { "key": "slots", "method": "GET", "service": "practice", "params": ["record", "from", "to"] }, { "key": "appointment", "method": "POST", "service": "practice", "params": ["record", "slot"], "form": [ { "key": "name", "type": "string", "required": true }, { "key": "email", "type": "email", "required": true }, { "key": "consent", "type": "consent", "required": true, "label": { "en": "I agree that my details are used to arrange the appointment.", "de": "Ich bin einverstanden, dass meine Angaben zur Vereinbarung des Termins verwendet werden." } } ], "rateLimit": { "perIpPerHour": 10 } }]A detail page with slots and without booking has no booking card beside it.
Endpoints
Section titled “Endpoints”An endpoint is something the portal may ask the package’s service, through nexxus.
| Key | Meaning |
|---|---|
key | Name, unique in the portal |
method | GET or POST |
service | A service of the package (services) |
params | Query parameters that are passed on. record must name a public record of the entity of the pages that use the endpoint; nexxus checks that before it calls. lang is reserved |
form | POST only. Fields with type string (up to 500 characters), text (up to 5000), email, number, date (YYYY-MM-DD), consent (must be ticked when required) or boolean (a box to tick, sent as true or false); required, label, maxLength. website is reserved |
rateLimit.perIpPerHour | Calls per visitor address and hour, up to 10000; without it 2000 for GET (a catalog with dates asks one quote per card) and 20 for POST |
Before anything reaches the service, nexxus counts the call against the rate limit, drops every parameter that is not declared, checks the record, and checks the form: unknown fields, missing required ones, wrong types, an invalid e-mail address or an unticked consent are refused with the fields named. The portal app renders a hidden field website that people never see; a request that fills it gets a friendly answer and goes nowhere.
What visitors see
Section titled “What visitors see”The portal app renders every page on the server first, so search engines read the content, and then continues in the browser. It stores nothing on the visitor’s device: no cookies, no local storage, no other browser storage. A site built on it needs no cookie banner. Say so in the privacy notice instead, as the holiday-home package does.
Every portal offers German and English, the site’s default language first. It offers Polish as well when the tenant’s language is Polish or the package brings Polish for its portal (a pl text in portal.name); a site that never chose Polish keeps its two languages. The visitor’s language comes from ?lang=, else from the browser (the first offered language of its Accept-Language header), else it is the site’s default. The portal’s own texts exist in all three. The package’s texts appear in the visitor’s language, else in the site’s default language, else in English, so a package without Polish texts shows a Polish visitor the default language’s ones.
Catalog
Section titled “Catalog”The search bar holds the filters, the dates and the number of guests; enum filters become chips above the list. Once dates are set, every card shows the price of that stay or that it is not free.
With map: true, list and map sit side by side on wide screens, and the list follows the map: when the visitor drags or zooms it, the list shows only what lies in the visible area, 400 ms after the map comes to rest, with the count 8 results in this map area. Search as I move the map switches that off for the visit; then a moved map offers Search this area. Show all brings every result back and fits the map to them. On phones the map is a view of its own (Show map); after moving it, Search this area applies the area. The area never becomes part of the address: a link someone shares and the page search engines see always show every result.
nexxus filters on the server. The records request takes bbox=W,S,E,N in degrees; a west edge greater than the east edge means the area crosses the date line. It compares the box with the same rounded location the markers show, so the list and the markers agree.
Listing page
Section titled “Listing page”- Share next to the title opens the phone’s share sheet; where there is none, it copies the link and says Link copied.
- The dates open Select dates, with check-in and check-out at the top. Past and booked days are struck through.
amenities: under What this place offers the values with their icons, in two columns from tablet width. After ten, a button like Show all 13 amenities opens the full list.reviews: the heading shows the average rating and the number of reviews. Up to six reviews follow with name, month, stars and text; a long text stops after four lines with Show more. A button like Show all 8 reviews opens every review. A review without a display name appears as Guest; a listing without reviews has no reviews section.host: Meet your host shows the firm’s symbol or logo (or its initial), its name, the settings offieldswith their labels, the introduction and Message the host. That button opens theinquirypage and takes the dates and guests the visitor chose along.

Free times
Section titled “Free times”A slots section shows the record’s free times one week at a time, Monday to Sunday, starting with the current week and never with a time that has passed. Previous week and Next week page up to days ahead. On wide screens the week is seven columns; on phones it is a list of the days that have free times. A time carries its label under it, such as the dentist. A week without free times says No free appointments this week and offers the next one. The times are shown in the zone the service names; when that is not the visitor’s own zone, a line under the week says which it is.
A chosen time opens Your appointment with the date and time and the form of request; Choose another time goes back and keeps what was typed. Request appointment sends it. After that the visitor sees Appointment requested with the reference the service returned. If someone else took the time in the meantime, the portal says This time was just taken. and loads the week again. On phones the bar at the bottom leads to the times.
Inquiry page
Section titled “Inquiry page”The inquiry page (in the holiday-home package stays/:slug/contact) is headed Contact Fynbos & Tide Holiday Homes with the site’s name, shows the host’s facts and the faq groups under Most guests ask about, then the form under Still have questions? Message the host. Beside it a card shows the listing’s photo and name with the dates and guests; Check availability goes back to the listing with them. After sending, the visitor sees the reference the service returned. The page is not in the sitemap, and search engines are asked not to index it.

Contact page, info pages and footer
Section titled “Contact page, info pages and footer”Pages with nav: true appear in the header; on phones they are in Menu. The contact page shows the firm’s card from the contact section (an address, a phone number to call, an e-mail address to write to, office hours) and the form of the form section; after sending it says Message sent with the reference. An info page shows its setting’s text as paragraphs; while that text is empty, the page answers 404 and its link disappears from the footer.

The footer is on every page: the site’s name and its address, phone number and e-mail address, the links to the footer: true pages and the copyright line with the current year. The portal finds them among the public settings by their key (it contains address; phone, tel or mobile; email or e-mail) and, for phone and e-mail, checks that the value looks like one.
Amenity icons
Section titled “Amenity icons”A value of an amenities field takes "icon": "<key>" in the manifest. The keys below are kept stable: new ones are added, none is renamed or removed. An unknown or missing key shows a check mark.
accessible, aircon, baby, balcony, bath, bbq, bed, bike, boat, car, check, coffee, dishwasher, dryer, fireplace, fridge, garden, gym, heating, hot-tub, kitchen, mountain-view, microwave, no-smoking, parking, pets, ev-charger, pool, safe, sauna, sea-view, self-checkin, shower, terrace, tv, beach, washer, wifi, winter, workspace
What the service receives and answers
Section titled “What the service receives and answers”nexxus calls POST <service url>/portal/<endpoint key> with an invocation token whose trigger is portal:<endpoint key>. There is no person behind it: when the service calls back, it acts as the installation’s service account, like for an event. The body:
{ "endpoint": "quote", "method": "GET", "params": { "record": 12, "check_in": "2026-12-20", "check_out": "2026-12-27", "guests": "4" }, "form": null, "locale": "en", "installation": { "id": 7, "version": "1.2.0", "settings": { "company_name": "Higgins Stays" } }}locale is the visitor’s language, de, en or pl. params are the declared parameters as the visitor sent them, record as a number. For a POST, form holds the checked fields with their types: numbers as numbers, consent as true.
For a slots section the portal asks the endpoint one week at a time: from and to are dates (YYYY-MM-DD), both included. The service answers with the free times of those days in the zone they are shown in; a day without free times may be missing or have an empty list, and a time in the past must not appear. When two people offer the same time, each of the two carries an id of its own, any string of up to 200 characters; the portal does not read it:
{ "timeZone": "Europe/Berlin", "days": [ { "date": "2026-10-22", "slots": [ { "id": "example-0830", "start": "2026-10-22T08:30:00+02:00", "end": "2026-10-22T09:00:00+02:00", "label": "Dr. Example" }, { "id": "sample-0830", "start": "2026-10-22T08:30:00+02:00", "end": "2026-10-22T09:00:00+02:00", "label": "Dr. Sample" } ] } ] }The request endpoint receives the chosen time’s id, or its start when it has none, exactly as the service gave it, as the parameter named by slotParam. Without ids, two times with the same start would arrive as the same value, so the portal shows only the first of them. The service checks the time again: if it is still free, it answers like a booking request ({ "ok": true, "reference": "T-0042", "message": { "de", "en" } }); if it has been taken, it answers 409, and the portal shows its own message and loads the week again.
The service answers with JSON, an object or a list, at most 64 KB. nexxus passes it to the portal unchanged. To refuse, the service answers 400, 404, 409 or 422 with { "errorCode", "message", "messages": { "de", "en", "pl" }, "fieldErrors": { "<field>": "..." } }, pl optional. nexxus passes these keys on as short plain text (a message given as { "de", "en", "pl" } in the visitor’s language, else in English, else in German) and further keys only as short codes, numbers or yes/no, such as "reason": "booked"; everything else is dropped. A service that cannot be reached or takes longer than 10 seconds becomes 503 SERVICE_UNAVAILABLE, any other failure 502 SERVICE_ERROR. The visitor never sees the service’s own error text.
Customer area
Section titled “Customer area”The portal also has a signed-in side: the clients of a broker see their own contracts, the owners of a holiday home their bookings. They are members, not users of the app. Each member belongs to one record of the package (their firm, their property), sees only what the package declares for their audience, and acts with the rights of the staff member who invited them, their sponsor. The record history shows the sponsor; the module log names the member.
The manifest declares an audience under portal.members, with the entity a member belongs to (scope), the records and fields they read, what they may create, their actions, their views and roles:
"members": { "kunde": { "label": { "de": "Kundenbereich", "en": "Client area" }, "scope": "firma", "start": "konto.start", "records": { "firma": { "fields": ["name", "ampel"] }, "vertrag": { "via": "firma", "fields": ["titel", "sparte", "ablauf", "dokument"] } }, "views": ["konto.start", "konto.vertraege"], "actions": ["nachweis-einreichen"], "uploads": { "folder": ["Kundenbereich", "{{scope.title}}"], "maxMb": 20 }, "roles": { "leitung": { "label": { "de": "Geschäftsführung", "en": "Management" }, "admin": true, "secondFactor": true }, "technik": { "label": { "de": "Technik", "en": "Facilities" }, "views": ["konto.start"] } } }}- Sign-in is on the portal domain at
/konto, by a link sent by e-mail, no password. A role withsecondFactoradds an authenticator code. Staff invite someone on the record’s page, tab Client area, or ask the assistant to; anadminmember manages their colleagues themselves. An invitation carries a language,de,enorpl(default the tenant’s), and the invitation and sign-in mails exist in all three. - Every page after sign-in is a view of the package with the slot
member, filling the window. nexxus draws only the sign-in pages around it, with the firm’s logo, so the area looks like the package and not like nexxus. What such a view may call is in Views and the view SDK. - What a member reads is checked on the server for every call: the path
viafrom a record to their scope record, the audience’s filters, their role’s fields. A record outside it answers404, as if it did not exist.
Consent at install
Section titled “Consent at install”A package with a portal asks for the consent portal:public, one with a customer area also for portal:members (who, which data, what they may do). The install dialog shows what becomes public in one sentence that nexxus builds from the manifest: which records, which fields, that images and a rounded location go along, and which service visitors can call. An update that publishes something else (another field, another filter, another endpoint) asks for the consent again; without it the package stays on its version.
Rules the package check enforces
Section titled “Rules the package check enforces”The upload is refused, with one line per problem, when a portal names an entity, field, page, endpoint or service that does not exist, publishes a field of a type that may not be public, filters on a field that is not indexed, repeats a page path, uses a page section with a field of the wrong type, or calls an endpoint with the wrong method. For slots it also checks that the section is on a detail page, that endpoint declares record, from and to, that request declares record and the slotParam, and that days lies between 7 and 90.