Authoring pages

A Moseik page is freeform HTML + CSS. You write ordinary markup; the platform sanitizes it, wraps it in a document shell, and serves it under a strict security policy. There is no composition JSON, no fixed component vocabulary, and no utility-class allowlist — if you can express it in sanitized HTML and CSS, you can build it.

The page model

A page payload is a small JSON object:

{
  "html": "<header>…</header><main>…</main>",
  "css": ".hero{ padding: 4rem 2rem }"
}

Create a page with POST /v1/pages, edit it with PUT /v1/pages/:id/payload (send If-Match: <latestVersionId>). See the API reference.

What HTML you can write

The sanitizer runs on every write and rejects (never silently strips) a payload that steps outside the envelope, with a machine-readable reason. The envelope:

summary h1–h6 p span a strong em ul ol li table … img picture source video audio button label; form controls form input textarea select option optgroup datalist output fieldset legend progress meter (for client-side interactive UIs — see below); <dialog> for modals (see _Modals_ below); inline <svg> (a safe subset — see _Icons, images & embeds_); <iframe> for embeds (see below); and the platform placeholders <mk-component> and <mk-partial>. Global attributes class id title role hidden tabindex style, plus data-* and aria-*, are allowed everywhere; element-specific ones (href, src, alt, colspan`, …) where they belong.

- <script> and inline on* handlers, javascript: URLs → per-site JS ships as an island (PUT /v1/script), never inline. This applies to form controls too — wire input/change/submit in the island, not oninput=…. - Inline <style> → page CSS goes in the payload css field. - <object>/<embed> and SVG <foreignObject> → plugins/foreignObject can smuggle code. - A <form action> (or formaction) pointing off-site → forms may only submit first-party (the page CSP pins form-action 'self'). - Off-site _asset_ URLs on src/srcset/poster and CSS url() must be first-party/relative — import media first (below). Two exceptions: an <iframe> src may be any https:// origin (that's what embeds are), and an <img> src/srcset (or a <picture> <source srcset>) may point at our own Cloudflare Images account, https://imagedelivery.net/fdADyrHW5AIzXwUyxun8dw/… — where Showroom headshots live. Any other imagedelivery.net account is refused. The served page's CSP still blocks third-party _scripts_ and _fetches_ at runtime (I13 as physics). - Dangerous URL schemes (javascript:, data:, …) on any attribute.

Styling

Six CSS layers cascade, in this order, into every page (the same order GET /v1/manifest reports as cssLayers):

  1. Custom @font-face — the self-hosted font faces (vendored + your uploaded fonts), emitted first so the families are available to everything below.
  2. Theme — :root custom properties (colors, fonts, the type scale, spacing, radius) compiled from the site's design tokens. Reference them as CSS variables: var(--color-primary), var(--font-heading), var(--font-mono), var(--type-scale-4), var(--space-4), etc.
  3. Base reset — a minimal reset + accessibility defaults. box-sizing, img { max-width: 100%; display: block; height: auto }, a { color: inherit }, a reduced-motion guard, and the two components the platform must ship looking operable (the lightbox and the consent banner). Everything here is zero- or single-specificity, so your CSS wins by default.
  4. Site stylesheet — your shared CSS (PUT /v1/stylesheet).
  5. Chrome CSS — the CSS attached to your header/footer/announcement.
  6. Page CSS — the payload's css, most specific, last to apply.

Links inherit their colour rather than falling back to browser blue. The platform emits anchors of its own — the listing card's link, the review author, the agent's phone number, a collection's call to action, the blog pager — and those class names are not in your repo, so styling only the anchors you wrote would leave the rest #0000EE. Set a { color: … } in your stylesheet and it wins outright; the underline is untouched either way.

There is no CSS framework shipped or required — write plain CSS. The convention (for your own maintenance, not enforced) is to lean on the theme's CSS variables and put anything shared in the site stylesheet. If you like utility-class authoring, define your own classes in the stylesheet.

One family of CSS bug is worth naming, because it has cost real time on real builds and every instance renders without an error: a definite size on the axis you weren't thinking about silently wins over the rule you meant to be in charge.

If a box comes out a size you can't explain, ask which axis is definite before reaching for !important.

Site stylesheet — shared CSS, edited once

GET  /v1/stylesheet                 → { css, publishedVersionId, … }
PUT  /v1/stylesheet   { "css": "…" }   → publishes + re-renders every page
POST /v1/stylesheet/revert { "toVersionId": "ver_…" }

Publishing the stylesheet re-renders the whole site, so a shared rule changes everywhere at once.

Components — behavior, headless

Embed a platform behavior component as a custom element; the renderer resolves it server-side into functional markup with stable mk-* class hooks and no styling opinion — you style the hooks via CSS.

<mk-component name="contact-form" data-phone="true"></mk-component>

Available components:

namepurposenotable data-*
site-fieldprints one field of the business contact record (phone, email, hours, address) as text — use this instead of typing contact details into the pagedata-field (closed set — see _Printing the contact record in a page_ below)
formany form with a field schema you declare → leads pipelinedata-fields (JSON, see below), data-form-id, data-submit-label, data-consent ("false" to drop the consent checkbox), data-consent-label
contact-formquick contact preset → leads pipeline — fixed field vocabulary; use form for anything elsedata-fields (comma list; only name, email, phone, message — anything else is rejected at the gate), data-phone, data-submit-label, data-consent-label, data-form-id
newsletter-formemail capture → leads pipelinedata-submit-label, data-consent-label, data-form-id
listingsreal-estate listings from the site's data feed, server-rendered so crawlers see them — provisioned when the site is created; check GET /v1/site → listings.enabled firstdata-city, data-neighbourhood, data-property-type (one value or a comma list), data-address, data-min-price/data-max-price, data-min-beds/data-max-beds, data-min-baths/data-max-baths, data-min-sqft/data-max-sqft, data-limit (default 12, max 48), data-featured ("agent"/"office" → only this site's own listings) — see _What you can filter by_ below
mapinteractive branded Google map — styled features, custom pins, info windowsdata-pins (JSON), data-style (Google styles array), data-lat, data-lng, data-zoom, data-title — see _Google Maps_ below
listings-mapa Google map pinning the listings already rendered on the page — pair it with a listings griddata-lat + data-lng (opens on your market without them), data-zoom, data-style (Google styles array), data-title — see _A map of your listings_ below

Printing the contact record in a page

The business's phone, email, hours and address live in one place — the contact record, written with PUT /v1/site { "contact": … } — and site-field prints one of them wherever you need it:

<p>Call <mk-component name="site-field" data-field="phone"></mk-component> or drop in.</p>
<address><mk-component name="site-field" data-field="address"></mk-component></address>

Use this instead of typing the details into your markup. It is the difference between a client changing their hours in one call and somebody hunting through every page that mentions them, and it is the same record the page shell builds Organization/LocalBusiness from — so the number a visitor reads and the telephone a search engine reads cannot drift. Writing the record re-renders the whole site, so every referencing page is correct afterwards with no page write of your own.

data-field takes one of these, and nothing else:

data-fieldrenders
namethe business display name
phonethe phone number as it was written
emailthe business's public email address
hoursopening hours, free text (Mon–Fri 9–5)
addressthe whole address on one line, no country
address.street12 Main St
address.cityRegina
address.regionSK
address.postalCodeS4P 3Y2
address.countryCA

Those are the record's own key names, exactly as GET /v1/site reports them under contact — so read the record and reference what you read. Anything outside that list is rejected at the gate, because there is nothing behind it: a tagline, a licence number, a second location or an individual agent's direct line are page content, not site config.

It emits bare text and no wrapper element. Every other component gives you a structure to style; this one gives you a value, so it has to be able to sit mid sentence or inside a heading. You own the element around it:

<a class="site-phone" href="tel:+13065550100">
  <mk-component name="site-field" data-field="phone"></mk-component>
</a>

Note the href there is written out. The component prints text, so it cannot fill an attribute — a tel: or mailto: href is a different problem and is not supported yet. Until it is, that one value is duplicated and you have to keep the two in step by hand.

A field with nothing on file renders as nothing — never the word undefined, and never visible placeholder text. That keeps a visitor from seeing a gap they can't interpret, but it also means an empty reference is invisible to you in a preview, so the write gate warns you about it (site_field.unset) with the fields that _are_ on file. If you get that warning, either fill the record in or drop the reference; don't ship the gap.

contact-form has four fields, and that is all of them

data-fields on contact-form is a closed list, not a starting value: name, email, phone, message. It is the one data-* in the table above that does not work the way "default" usually reads — you cannot extend it by naming something else, because the preset has no markup for a field it does not know.

Need any other field — first_name, company, budget, a dropdown? Use the form component, which renders exactly the schema you declare:

<!-- WRONG: first_name/last_name are not part of the preset -->
<mk-component name="contact-form" data-fields="first_name,last_name,email"></mk-component>

<!-- RIGHT: declare the fields you actually want -->
<mk-component
  name="form"
  data-fields='[{"name":"first_name","label":"First name","required":true},
               {"name":"last_name","label":"Last name"},
               {"name":"email","type":"email","label":"Email","required":true}]'
></mk-component>

Writing an unknown name used to publish a 200 and silently drop the field — two live forms collected enquiries with no name attached for months, and the rendered form looked completely ordinary. The gate now rejects it at write time and names the permitted set, so a dry run catches it.

The same rule holds for the other closed sets: data-featured is agent/office on listings (or bare true/mine for "this site's own") and true/false on collection — different vocabularies for the same attribute name — and data-property-category is residential/commercial/agriculture. data-property-type is the exception: its values come from your feed, so an unrecognised one is a warning rather than a rejection. Read the real values with GET /__data/listings/facets?fields=PropertySubType — data-property-type="Commercial" is a category, matches no subtype, and renders an empty grid.

A form renders <form>/<input> markup with hooks like .mk-form, .mk-form__field, .mk-form__label, .mk-form__input, .mk-form__submit — style them in your stylesheet. Submissions POST first-party and land as leads; you never wire the backend. Referencing a component name that doesn't exist is rejected at the gate.

Declare forms as components — never hand-write <form>

Always use <mk-component name="contact-form|newsletter-form|form">. Writing your own <form> element does not work, and fails in the one way you cannot see from the authoring side: the submit URL is minted per form at render time, and only a declared component is registered to receive it. A hand-written <form> posts to a URL that 404s. The page will look perfect.

The platform adds five things to your form at render that are not in your source:

AddedWhy
actionthe first-party submit URL, unique to this form
data-mk-formidentifies the form to the platform
hidden website fieldspam honeypot
hidden mk_ft fieldsigned anti-bot token, proving the page was rendered by us
/__mk/forms.js script tagfills a hidden mk_fn per-load nonce in the browser

That script also submits the form and confirms it, without reloading the page — see [After a submit](#after-a-submit) below. You do not write a submit handler.

Four rules follow from that, and they are the whole story:

  1. Reserved field names: consent, website, cf-turnstile-response, and anything starting mk_. A collision makes every submission fail, so the gate rejects it at write time.
  2. Style by class, never by position. .mk-form__field--<name> targets one field; :nth-child / :first-child on form children will land on a hidden field you didn't write.
  3. Bot protection is automatic. Don't add a captcha, don't try to generate mk_ft or mk_fn, don't remove hidden fields, and don't strip the /__mk/forms.js script tag.
  4. Never paste rendered form HTML back into a payload. Re-declare the component instead; a copied token is stale markup and a copied nonce is expired.

If submissions start returning 403 (verification failed — please reload the page and retry), the visitor was served a page rendered before the current token, or before /__mk/forms.js existed. Ask the operator to re-render the site. Nothing you can change in the page payload fixes it — removing the hidden fields makes it worse.

Submitting to someone else's system

Some forms are not lead capture. A resort books through a reservation engine, a venue sells through a ticketing provider — the conversion path leaves the site by design, and posting it into the leads pipeline would be wrong.

data-action on <mk-component name="form"> submits to that system instead:

<mk-component
  name="form"
  data-form-id="booking"
  data-action="https://www.reseze.net/servlet/WebresShowAvailable"
  data-fields='[
    {"name":"hotelid","type":"hidden","value":"1315"},
    {"name":"arrival","label":"Arrival","type":"date","required":true},
    {"name":"departure","label":"Departure","type":"date","required":true},
    {"name":"adults","label":"Adults","type":"number"}
  ]'
  data-submit-label="Check availability"
></mk-component>

The origin must already be on this site's approved external-origin allowlist, or the write is rejected. It is not optional: the page CSP pins form-action, so an unapproved origin would be refused by the browser anyway — the gate just tells you at write time instead of letting you ship a form that silently cannot submit. Proposing and approving the origin is two calls you can make yourself, with the sign-off of the person you are working for on the second one.

It is a real <form>, so submit-on-Enter, required and browser validation all work with no JavaScript. Three things are deliberately dropped, because all of them are addressed to us and this submission is not: no lead is stored, no spam honeypot, no anti-bot token. data-method picks get (default) or post.

Constants must be hidden fields, not query strings. data-action="…?hotelid=1315" loses hotelid the moment it submits, because a GET form discards its own action's query string and rebuilds it from the fields. Use {"type":"hidden","value":"…"} — that is what the type is for.

Consent defaults off here (we store nothing, so there is nothing to consent to); add data-consent="true" if the receiving system expects it.

After a submit

The form posts in the background and is replaced in place by a confirmation — a checkmark and a message. The page does not reload. If the submission fails validation, the reason appears beside the form (.mk-form__error) with everything the visitor typed still there.

All of this is automatic. Do not write a submit handler, a thank-you page, or a ?submitted=1 check.

That last one deserves a reason, because it is the obvious thing to reach for and it cannot work: a page is one cached object served to every visitor, so the server cannot render a per-visitor success state. GET /contact and GET /contact?submitted=1 return the same bytes. The confirmation has to happen in the browser, which is why the platform does it for you.

To change the words, put data-success-message on the component:

<mk-component
  name="contact-form"
  data-success-message="Thanks — we'll be in touch within one business day."
></mk-component>

To change the whole thing, author your own element and mark it data-mk-form-success. Put it beside the form, or anywhere on the page if you give it the form's key. Add hidden so it does not appear before submitting:

<mk-component name="contact-form" data-form-id="enquiry"></mk-component>

<div data-mk-form-success="enquiry" hidden class="thanks">
  <h3>Message received</h3>
  <p>We reply to every enquiry within one business day.</p>
  <a href="/case-studies">Read our case studies while you wait →</a>
</div>

Yours is used instead of the default, and it is styled entirely by you — this is freeform HTML/CSS like everything else, not a themeable widget.

Tracking a conversion? Do not use a thank-you URL — there is no navigation to count. A successful submit fires both of these:

// bubbles from the form
document.addEventListener("mk:form-success", (e) => {
  console.log(e.detail.formKey);
});
// and, if a dataLayer exists
window.dataLayer.push({ event: "mk_form_success", formKey: "…" });

Visitors with JavaScript disabled still submit successfully — they get the classic redirect to ?submitted=1 instead of the in-place confirmation.

Forms require JavaScript: the nonce is minted per page load rather than baked into the cached HTML, which is what stops a bot harvesting one hidden field and replaying it forever. It is on for every site; only an operator can exempt one.

Two fields on one row

Add data-columns="2" (or "3") and style the hook with grid:

.mk-form--cols-2 {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}
/* Anything that should span the full width: */
.mk-form--cols-2 .mk-form__field--message,
.mk-form--cols-2 .mk-form__consent,
.mk-form--cols-2 .mk-form__submit {
  grid-column: 1 / -1;
}

Use grid, not inline-block with calc() widths. Two inline-block boxes are separated by a text space in any markup with whitespace between them, so calc(50% - 7px) pairs with a 14px gutter come to 100% + 4px and wrap. The platform emits form fields with no whitespace between them, so inline-block does work; grid is still the recommendation because it needs no arithmetic and no assumption about our markup.

Every field also carries .mk-form__field--<name>, so you can target one field (.mk-form__field--first_name) without changing the field order.

On a site with a bot challenge enabled, the platform inserts one more block inside every form, just above .mk-form__submit: .mk-form__turnstile, wrapping a widget you do not control. Leave room for it and do not display: none it — a challenge a visitor cannot see is one they cannot solve, and their submission is then rejected.

Verify a form end-to-end before calling it done: submit it, then check GET /v1/leads. A form can render perfectly and still not deliver — that failure happens on the visitor's POST, so no authoring call will ever report it.

Listings must be provisioned for the site — you cannot turn them on

Check this before you build a real-estate site, not after. listings renders an empty collection, and listing detail routes 404, unless a listings provider is enabled for the site. There is no /v1 endpoint that enables it, and no amount of page markup works around it — Moseik sets it when the site is created, not this site's token.

GET /v1/site tells you where you stand:

"listings": { "enabled": true, "market": { "province": "Saskatchewan" },
              "featured": { "available": true, "officeKey": "264739" } }

or, when it's off:

"listings": {
  "enabled": false,
  "reason": "listings.not_provisioned",
  "enabledBy": "Moseik, when the site is set up — not this site's own token"
}

A scope that is wrong can be corrected — ask Moseik. "The market is wrong" is a fixable request rather than a permanent constraint to design around.

What it shows: the whole market. Every listing in the site's province (and city, if one is set) — not just the site owner's own listings. That's what the feed is for.

Featured / "my listings" sections. Add data-featured="agent" (or "office") to narrow a grid to the site's own listings, using keys held in the site config:

<!-- the whole market -->
<mk-component name="listings" data-limit="12"></mk-component>

<!-- just this agent's own listings -->
<mk-component name="listings" data-featured="agent" data-limit="6"></mk-component>

One named agent, on a brokerage site. data-agent-key="<MemberKey>" is the single exception to the rule below, for an agent's own landing page inside a brokerage's site:

<mk-component name="listings" data-agent-key="1520210" data-limit="6"></mk-component>

Use the MemberKey that agent-roster renders, never a name. The platform pairs it with the site's own office key before the query leaves, so it can only ask "this agent, at this brokerage" — a key belonging to somebody else, or to an agent who has since moved, renders an empty grid rather than the wrong person's listings. Two things to check, because both render perfectly: data-mk-agent-unscoped on the wrapper means the site has no office key to contain the request, so the grid is the whole market under whatever heading you wrote; data-mk-agent-empty means the pairing worked and matched nothing, which is either a genuinely empty roster entry or a wrong key.

Otherwise, never paste an office or agent id into markup — it goes stale the day the agent changes brokerage. GET /v1/site → listings.featured.available tells you whether the site has a key to feature by; without one, data-featured falls back to the whole market rather than rendering an empty section.

available: false is fixable. Ask Moseik to set the brokerage's office key, giving the brokerage's name — the key is looked up from the name, so nobody needs to know it.

Why you can't set the market yourself. The province and city come from the site config and are applied after your attributes, so a page cannot show a different market — there is deliberately no data-province attribute, and the listing detail route re-checks the market too. It's a per-site setting because it determines both what the site is for and how much of a ~227k-row national feed each query touches.

If enabled is false, say so plainly to whoever asked for the site and stop — build the rest of the page and leave the grid in place. Do not hard-code listing markup, paste in sample listings, or substitute images to make the section look finished: the grid will fill in by itself the moment the provider is switched on, and fake listings in a real-estate page are a compliance problem of their own.

Turning on a builder's inventory yourself

You can do this one without staff. MLS listings are set up by Moseik, because the market is a licensing claim and the keys belong to a brokerage. A builder's own new-construction lots are that site owner's own public data, so the whole setup is two calls on the site's own token.

1. Find the builder.

GET /v1/listings/builders?q=Fraser
{
  "builders": [
    {
      "orgId": "org_7Kd2",
      "builder": "Fraser Homes",
      "lots": 42,
      "cities": ["Saskatoon", "Warman"],
      "sample": "902 Spadina Crescent E, Saskatoon Saskatchewan"
    }
  ]
}

Matching is a case-insensitive substring of the builder's name; omit q to list everyone. Read the lots, cities and sample before you choose. An org id is opaque — every candidate looks equally plausible, and the count and a real address are the only things that tell you it is the right company.

2. Point the site at them.

PUT /v1/listings/scope   { "orgIds": ["org_7Kd2"] }

The response names whose homes are now on the site:

{ "configured": true, "builders": ["Fraser Homes"], "lots": 42, "province": "Saskatchewan", "rerender": "queued" }

Check that name. It is the one moment a wrong pick is cheap to fix — call again with the right org and nothing else has to be undone.

The province is derived from the lots and decides only where a map opens; send one yourself only if you are told it could not be. city is accepted the same way and is also map-only. The site re-renders automatically.

Refusals worth knowing, all of them before anything is stored: an org with no published lots is listings_scope.no_inventory (almost always a mistyped id), and a site already on the MLS feed is listings_scope.mls_configured, because one grid cannot merge both sources yet.

3. Build, detail route first. Publish the page whose route carries /listings/:mls/:slug before any page with a grid — cards only get their internal link when that route already exists at render time, and on this feed a card with no link has no link at all (there is no REALTOR.ca to fall back to). Then write the grid pages.

Two feeds: MLS resale, or a builder's own lots

GET /v1/site → listings.provider says which one this site reads. ddf is the MLS feed described above. evrylist is a home builder's own new-construction inventory — same components, same detail route, different data.

On a builder's site:

Styling the listings grid

Hooks: .mk-listings, .mk-listings__grid, .mk-listing, and per-card .mk-listing__photo, __price, __address, __facts, __mls, __brokerage, __realtor (the per-listing REALTOR.ca text link), plus the collection's .mk-listings__attribution wrapper holding .mk-listings__realtor-logo and .mk-listings__trademark. Style it however the site's design calls for.

A card that is not simply for sale carries data-mk-status="pending" or data-mk-status="sold" on .mk-listing, plus a .mk-listing__status line inside it. An available one carries neither, so [data-mk-status] selects exactly the listings that need calling out — which matters most on builder inventory, where a sold lot stays in the grid.

The whole card is already a link, and you do not wire that up. .mk-listing__link is an empty anchor stretched over the card by the base layer, so clicking the photo, the address or anywhere else opens that listing's detail page — the photo and the address are not inside an anchor themselves. .mk-listing__realtor is lifted above the overlay so the REALTOR.ca link still works; do not cancel that, it is a per-listing licence obligation.

Two consequences worth knowing:

z-index: 2 the way .mk-listing__realtor` has.

The base layer also gives .mk-listings__grid an auto-filling responsive grid and strips the <ul> bullets. Replace it with whatever the design calls for — every rule is a single class, so your rule wins.

A card with no photo carries data-mk-no-photo, so you can size and fill the gap. Put the placeholder height on the card or a wrapper — not on .mk-listing__link, which is the click overlay and has no layout of its own:

.mk-listing[data-mk-no-photo] { min-height: 22rem; }

Don't put words in that placeholder. "No photo yet" is a claim about the listing, and it is often wrong: a property reaches the feed before its photographs do, so the newest listings render photo-less and the grid refreshes every 6 hours — measured on a live site, two of three photo-less cards already had 22 and 42 photos. Style the space, don't narrate it.

One logo per collection, one link per card, and don't rearrange that. The logo appears once per grid; each card carries its own text link to _that listing_ on REALTOR.ca, which is a per-listing licence obligation a page-level badge cannot satisfy. Hiding .mk-listing__realtor with CSS breaks it just as surely as deleting it, so style the link — don't suppress it.

object-position: top left is already the default on .mk-listing__photo — you only need the sizing:

.mk-listing__photo {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover; /* object-position: top left comes from the base layer */
}

Listing photos arrive from CREA with the REALTOR® watermark burned into the top-left pixels, and displaying it is a licence condition. Normalising cards to a uniform aspect ratio with object-fit: cover crops the image, and a centred crop is exactly what removes that corner — which is why the platform sets the origin for you. Feed photo aspect ratios vary widely (measured from 730×544 to 1600×1200), so cropping of some kind is usually unavoidable.

Override object-position if a design needs it, but understand what you are moving. If a surface does crop aggressively, give visitors a route to the full uncropped image (a lightbox on .mk-gallery, for instance).

Pair the grid with a map, on the same page. A property search with no map reads as unfinished next to any portal a client will compare the site against — see [_A map of your listings_](#a-map-of-your-listings--the-listings-map-component). It takes no attributes: drop <mk-component name="listings-map"></mk-component> beside the grid, give the hook a height, and it follows whatever the grid asked for. The gate warns (render.listings_no_map) on a filterable search page or a listing detail page that has none.

If the site you are cloning keeps its map on a separate page, do not inherit that. It is one of the few source decisions worth overriding: a map reached from the footer is a page nobody visits, while a map beside the grid is the search itself. Pair them and tell the client you did, or ask first — but do not let the split carry over unexamined because the original had it.

The default shape of a property search

Unless the client asks for something else, build it as a two-pane split: filters across the top, a sticky map beside a scrolling column of cards. That is the layout of every portal their buyers already use, and it is the shape listings-map was designed for — clicking a pin scrolls its card into view, which only does anything when the cards are in a scrollable column. The three rules that produce it are in [_A map of your listings_](#a-map-of-your-listings--the-listings-map-component).

Take the layout, not the look. Where the design work belongs — all of this is meant to differ from site to site:

What should not change without a reason from the client: that there is a map, that it sits beside the list rather than below it or on its own page, and that the filters sit above both.

Give it a mobile story or it ships broken. A 1fr 1fr split at 80vh is unusable on a phone, and squashing it is the most common way this layout goes out wrong. Stack it:

@media (max-width: 900px) {
  .split {
    grid-template-columns: 1fr;
    height: auto;
  }
  .split .mk-listings {
    height: auto;
    overflow: visible;
  }
  .mk-listings-map {
    height: 50vh;
    position: static;
  }
}

Keep the list as the phone's default view — a map above it, or behind a toggle. Cards are what a visitor scrolls; the map is how they orient.

What you can filter by

Every filter below works in three places, under the same names: as a data-* attribute on the listings component (server-rendered, so crawlers index the result), as a query param on /__data/listings (interactive), and — on a grid that opts in with data-url-filters — as a query param on the page's own URL, which is what makes a search shareable. Use the component for the page's default state, the URL for what the visitor searched, and the endpoint for repainting without a reload.

FilterComponent attributeQuery param
citydata-citycity
place match modedata-match (exact)match (or cityMatch)
neighbourhooddata-neighbourhoodneighbourhood
MLS® number—mls (exact, one result)
property classdata-property-categorypropertyCategory
property typedata-property-type (one, or a comma list)propertyType
dwelling typedata-structure-type (one, or a comma list)structureType
street addressdata-address (free text)address
pricedata-min-price / data-max-priceminPrice / maxPrice
bedroomsdata-min-beds / data-max-bedsminBeds / maxBeds
bathroomsdata-min-baths / data-max-bathsminBaths / maxBaths
square feetdata-min-sqft / data-max-sqftminSqft / maxSqft
keep unstateddata-include-unknownincludeUnknown
orderingdata-sortsort
results per pagedata-limit (default 12, max 48)limit
page—page

A multi-word place needs data-match="exact". The feed matches data-city and data-neighbourhood by token-OR, so data-city="Fort Frances" returns every row containing Fort or Frances — 2,011 rows for a town with 18, including Fort Erie, Milton and one property in France. A board-prefixed neighbourhood carries more tokens and goes wronger: "334 - Crescent Park" is 3,647 rows for a neighbourhood with 96. The page renders perfectly and searches the wrong thing.

<mk-component name="listings" data-city="Fort Frances" data-match="exact">
</mk-component>

Four more things that bite, all of them invisible until a page is live:

- The unstated bucket dominates, so don't label the result as a range. On Ontario, minSqft=1500&maxSqft=2500 goes from 5,911 to 93,164 listings with the flag. That is the correct answer to "1,500–2,500 sq ft or unstated". - includeUnknown=price brings the lease inventory back (lease rows carry 0), so don't pair it with sort=price-asc unless you mean to. - Send it to /__data/listings/facets as well, or your filter counts describe a different set than the grid they filter. Both endpoints take the identical filters.

Ordering the results — data-sort

newest · oldest · price-asc · price-desc. The order applies to the whole result set, not to the page you were handed, so "the ten cheapest homes in this market" is one query and page 2 genuinely follows page 1.

<mk-component name="listings" data-sort="price-asc" data-min-price="50000"></mk-component>

This paragraph used to say there was no price sort and that ordering applied within a page only. Both were true and both were fixed upstream on 2026-08-14 — the price sorts existed under names nobody guesses, and the pagination-before-sort bug that made newest page-local is gone (re-measured: monotonic across page boundaries in both directions). Anything you built to work around either can be deleted.

Before you write any dropdown, read Ask what values exist below: the feed answers a value it does not hold with an empty grid and no error, so a control offering options nobody verified goes live looking finished and matching nothing.

Interactive filtering — GET /__data/listings

The listings component renders server-side, so crawlers see the listings in the initial HTML. For a filter UI a visitor drives — city dropdown, property type, paging — fetch this same-origin endpoint from your site JS instead of re-rendering the page:

const res = await fetch(`/__data/listings?city=${encodeURIComponent(city)}&limit=12&page=1`);
const { listings, html, page, totalPages, total } = await res.json();
document.querySelector("#results").innerHTML = html; // ← prefer this

Params: city, neighbourhood, propertyType, propertyCategory, structureType, address, minPrice/maxPrice, minBeds/maxBeds, minBaths/maxBaths, minSqft/maxSqft, includeUnknown, sort, bbox, limit (default 12, max 48), page. Anything else is ignored. The site's province always applies and cannot be widened from the query string.

A malformed numeric bound, an unknown sort and an unknown includeUnknown field are all a 400, never a silent no-op — a dropped filter would return a wider grid that looks like it worked. 0 is a valid bound (minBeds=0 includes studios and land; note 0 on _sqft_ or _price_ is a narrowing filter rather than "no minimum", per the bounds note above).

A search box needs both address and mls. address is free text matched against the address and does not match an MLS® number — measured, address=X13673734 returns nothing. So route what the visitor typed: if it looks like an MLS number send mls=, otherwise send address=. Both come back in the same shape, so you paint the result the same way either way.

mls= is an exact lookup returning at most one listing, and it respects the site's market — an MLS number from another province returns empty rather than opening. Partial or type-ahead MLS matching is not available yet.

A feed outage is a 502, never an empty result. A 200 with zero listings means the filters genuinely matched nothing; anything else means the feed did not answer. Handle them differently — painting "no listings match those filters" over an outage makes your filter look broken. Retrying once on a 502 is reasonable; these are usually transient.

A 504 is different, and must not be retried. It means the feed ran out of time on _that query_, and it is deterministic: paging is skip/limit upstream, so cost grows with depth — page 100 of an Ontario feed answers in under two seconds, page 1,750 never answers at all. Retrying spends the same budget again for the same failure. Ask for less instead: an earlier page, a narrower filter, or a map viewport. This is also why deep paging is a poor way to browse a large market.

Filtering the server-rendered grid

Put the page's default filters on the component, so the results a crawler sees are the results the page is about:

<mk-component
  name="listings"
  data-min-beds="3"
  data-min-baths="2"
  data-min-price="400000"
  data-max-price="900000"
  data-limit="24"
></mk-component>

For "all commercial" or "all residential", use data-property-category. The feed has no category above subtype and no Commercial value at all, so the platform expands a class into the subtypes it covers:

<mk-component name="listings" data-property-category="commercial"></mk-component>
classcovers
residentialdwellings — Single Family (which in this feed includes condos and townhouses) and Multi-family
agriculturefarmland, plus Vacant Land
commercialeverything else — Retail, Industrial, Business, Office, Hospitality, Parking, Institutional, Recreational, Other, and Vacant Land

Vacant Land is deliberately in both agriculture and commercial — a bare parcel is legitimately either depending on who is buying, and a buyer shouldn't have to guess which tab we filed it under. So the classes overlap; they don't partition.

data-property-type still takes a comma list if you want to name subtypes exactly, and it wins when both are set — a page that named its subtypes meant them:

<mk-component name="listings" data-property-type="Office,Retail,Industrial,Business"></mk-component>

Filtering in the browser instead — fetching a wide set and hiding cards with CSS or JS — breaks two things quietly: the indexed grid stops matching what a visitor sees, and the totals you page against still describe the unfiltered set, so "page 3 of 11" becomes fiction.

A search people can share — data-url-filters

A listings page where filter state lives only in JavaScript is broken in a way nobody reports: a visitor cannot send their search to anyone. Copying the URL sends the unfiltered page, a community page cannot link to its own listings, and the back button walks out of the search entirely.

Add data-url-filters and the grid reads the page's own query string server-side:

<mk-component name="listings" data-url-filters="all" data-limit="24"></mk-component>

/listings?city=Warman&minBeds=3 now renders those results in the HTML the server sends. That is the part you cannot build yourself — your JS can repaint the grid after the page loads, but it cannot pre-filter the markup the server already sent, so without this a shared link always paints the whole market first.

Accepted names are the /__data/listings ones, not the data-* spellings:

city, neighbourhood, propertyType, propertyCategory, structureType, address, mls, minPrice, maxPrice, minBeds, maxBeds, minBaths, maxBaths, minSqft, maxSqft, includeUnknown, sort, limit, page

Name a subset instead of all when only some should be URL-driven: data-url-filters="city,propertyType,minPrice,maxPrice".

What the URL does to the grid:

The wrapper publishes what happened:

attributemeaning
data-mk-url-filtersfilters taken from the URL, comma separated
data-mk-droppedfilters named in the URL whose value could not be used
data-mk-pagethe page number being shown
data-mk-defaultsthe page's own data-* filters, as JSON
.mk-listings[data-mk-dropped]::before {
  content: "Some filters in that link weren’t understood — showing a wider search.";
}

The page's scope survives a search. A grid's own data-* attributes are what the _page_ is about; the query string is what the _visitor_ asked for. Both apply, and the grid publishes the first as data-mk-defaults so the filter form's repaint merges them exactly as the server does. So on

<mk-component
  name="listings"
  data-property-type="Single Family,Multi-family"
  data-limit="48"
  data-url-filters="city,minBeds,page"
></mk-component>

a search for Caledon returns Caledon residential listings, 48 a page — whether the visitor followed a link, submitted the form, or reloaded. .reset() and a cleared form return to that scope, not to the whole market: a residential route resets to residential.

Only a grid with data-url-filters publishes it, and only the filters the page actually sets. It is informational — you never write it.

featured is deliberately not a URL filter. It selects the site's own listings from keys held in site config, so accepting it from the query string would let anyone turn a whole-market page into "our listings" (or the reverse) by editing the URL.

Leave the attribute off an editorial grid. A homepage strip headed "Our featured listings" must not be re-filterable by a stranger appending ?city=Toronto. Opt in on the page that _is_ a search; every other grid stays what it says it is.

Indexing. A URL carrying filters is served per request and marked noindex, and its canonical points back at the bare route. Filter permutations are unbounded, so indexing them would dilute your real listings page across thousands of near-duplicate URLs. The bare /listings is unaffected — still statically cached, still indexed.

The filter form — .mk-listings-filters

Write an ordinary GET form. The control name is the filter name:

<form class="mk-listings-filters" method="get">
  <select name="city" data-mk-facet="City">
    <option value="">Any city</option>
  </select>
  <select name="propertyType" data-mk-facet="PropertySubType">
    <option value="">Any type</option>
  </select>
  <input type="number" name="minBeds" min="0" placeholder="Beds" />
  <input type="number" name="maxPrice" min="0" placeholder="Max price" />
  <button type="submit">Search</button>
</form>

The platform ships no markup and no styling for this — the form above is yours, class hook aside. With JavaScript switched off it still works: the browser submits it, the URL becomes ?city=…, and the server renders that grid.

With JavaScript the runtime adds the parts every site was rebuilding:

The URL holds the search, not the page's scope — ?city=Caledon, never the page's own propertyType. So a shared link stays short, and it keeps meaning "Caledon listings on this page" even after you change what the page is scoped to. The one thing it does spell out is a cleared control the page defaults (?minBeds=), because otherwise reloading that link would bring the default straight back under an empty box.

A control whose name is not a filter is ignored — it changes the URL and returns the same listings, which looks like a search that ran. The gate warns about both that and a control naming a filter the grid left out of data-url-filters (which works until the link is shared, then silently doesn't).

data-mk-facet="City" fills an empty <select> from the values the feed actually holds (any of City, PropertySubType, CityRegion, StateOrProvince, StructureType). A list you type by hand goes live looking finished and matching nothing — that has shipped twice. Options you write yourself are never overwritten — which is the right choice for a sort control, whose four values are ours rather than the feed's.

Other attributes: data-mk-auto on the form submits on every change (no button); data-mk-target="#results" picks the grid when a page has more than one.

Style the states — both are absent until they apply:

.mk-listings-filters[data-mk-loading] button { opacity: 0.6; }
.mk-listings-filters[data-mk-error]::after { content: "Listings are temporarily unavailable."; }

For controls that aren't a form — a "3+ beds" chip row, a saved search — use window.mk.listingFilters: .apply({ minBeds: 3 }) merges and re-runs, .reset() clears, .current() returns the active filters. The form also emits mk:listings:updated (with total, totalPages, page) and mk:listings:error.

Linking to a pre-filtered search

Once a page has data-url-filters, any other page links straight into it:

<a href="/listings?city=Warman&minBeds=3">Homes in Warman</a>

Use the filter names exactly — they are case-sensitive, and the wrong one is silent: the link works, the page loads, the grid renders the unfiltered market. ?beds=3 and ?neighborhood=Sutherland both read perfectly and filter nothing (it is minBeds, and the Canadian spelling neighbourhood). The write gate warns on links whose params are not real filter names, but the target page still needs the attribute or the link is inert.

Separating two params, write &amp; — the ordinary HTML escape, and what a validator expects inside an attribute:

<a href="/all-listings?city=Saskatoon&amp;neighbourhood=Brighton">Brighton homes</a>

A bare & works too (browsers recover), and the gate reads both the same way. It did not always: it used to read the attribute undecoded and warn that amp;neighbourhood was not a filter, on links that were perfectly correct. If you are carrying a workaround that strips the &amp;, you can drop it.

Ask what values exist — GET /__data/listings/facets

Build every filter control from this, never from a list you typed. The feed answers a value it does not have with an empty result and no error, so a dropdown of invented options goes live looking finished and matching nothing — a residential page shipped with House / Condo / Townhouse options that do not exist in the feed, and a commercial page shipped blank because there is no Commercial value to ask for.

const res = await fetch("/__data/listings/facets?fields=PropertySubType&city=Orangeville");
const { total, facets } = await res.json();
// facets.PropertySubType.values → [{ value: "Single Family", count: 177 }, …]

fields accepts PropertySubType, StateOrProvince, City, CityRegion, StructureType (default PropertySubType,City); anything else is a 400 naming what is supported.

It takes the identical filters /__data/listings does — every bound, structureType, bbox, includeUnknown, all of them — so hand it the same query string as the grid and the counts describe that grid. Passing fewer is how a viewport-filtered map ends up beside province-wide counts, and includeUnknown is the case where it is measurable rather than merely untidy: on minSqft=1500, the facet says 5,297 where the grid shows 79,876.

exact: true means the counts are complete. City, CityRegion and StructureType are open vocabularies and come back sampled — the values are real, the counts are indicative. Scope a StructureType facet (province + propertyType=Single Family) and the residential vocabulary resolves cleanly; unscoped, it is a list of whatever a large sample happened to hold.

Pagination — page against totalPages, never off a full page

The response carries page, totalPages and total, which is what you build next/prev from:

const { html, page, totalPages } = await res.json();
nextButton.disabled = page >= totalPages;

Do not infer the last page from "this page came back short". Under DDF radius search the totals over-count (a bounding box, ~27% high) and a page inside a larger set can legitimately return fewer rows than limit, so a short page means nothing. totalPages is the only reliable end-of-set signal, and total is an upper bound rather than an exact count — label it "about N results" if you show it.

On the SERVER-RENDERED grid a feed error returns total: 0, totalPages: 0, so a next-page control disables itself rather than pointing at nothing — a page must never fail over one section. On /__data/listings the same event is a 502 (or a 504 for a timeout), never a 200 with zero rows, because there a caller can react.

?page= works on the page URL only if the grid opted into data-url-filters. With it, /listings?page=2 is a real server-rendered page you can link to — the page is served per request rather than from its cached R2 object. Without it, published pages are cached per route with the query string outside the cache key, so /listings?page=2 serves page 1's bytes; build the pager against this endpoint instead.

Listing detail pages — one template, every listing

Publish a page whose route carries the pattern /listings/:mls/:slug and put <mk-component name="listing-detail"> in it. The router resolves that segment, binds that listing, and serves the template — so you author one page and get every property. A wrong slug on a correct id 301s to the canonical URL; an id that has left the feed answers 410, and one that never existed 404s.

The :mls segment holds whatever identifies a listing on this site's feed: an MLS number on the MLS feed, a lot id on a builder's. {{listing.mls}} renders empty on a builder's lot, so do not build a title around it.

> Publish this route BEFORE any page with a grid. A card's internal link is added > only when this route already exists at render time; without it every card links out to > REALTOR.ca and nothing reaches a listing page. Publishing the route later does not fix > grids that are already live — re-push them. The write gate warns > (listings.no_detail_route) if you publish a grid first, but it cannot re-render pages > you published earlier.

Each listing gets its own <title> and description, derived from the bound listing — you do not have to do anything for that. Left as a plain title like "Listing", the served title becomes "<address>, <city> | <Site Name>", and the description comes from the feed's own remarks (trimmed to 160 characters on a word boundary).

To control the format yourself, put tokens in the page's title or meta description:

title: "{{listing.address}}, {{listing.city}} — {{listing.price}} | Acme Realty"

Available: {{listing.address}}, {{listing.city}}, {{listing.province}}, {{listing.mls}}, {{listing.price}} (the lease figure when there is no sale price), {{listing.beds}}, {{listing.baths}}, {{listing.sqft}}, {{listing.brokerage}}.

Three behaviours worth knowing:

Tokens work in the title and meta description only, not in page HTML.

You do not need an h1 of your own. listing-detail emits the address as the page's <h1> (.mk-listing-detail__address), which is the reason tokens are not needed in page HTML — and the reason a static heading is the wrong fix. "Property for sale" on every listing is the duplicate-signal problem the per-listing title above exists to avoid.

Auditing this page audits a real listing. GET /v1/pages/:id/verify, GET /v1/pages/:id/screenshot and the write gate's lints all bind one representative listing — the one with the most photos out of a sample — before measuring, because the template on its own renders an empty placeholder. Each says which listing it used (audit / X-Mk-Bound / an audit.bound_sample warning). It is one listing, so a finding about a 40-photo gallery is evidence about that gallery, not all of them.

Every photo is emitted, and the base layer lays them out. listing-detail emits every photo the feed carries for that listing — often 20–40 of them — as repeated .mk-listing-detail__photo elements inside a .mk-listing-detail__gallery wrapper. Cap it with data-photos="6" (default 12, max 48). A one-photo listing emits the bare .mk-listing-detail__photo with no wrapper, so single-photo CSS keeps working.

The wrapper arrives as a responsive thumbnail grid (4/3, cropped top left so the REALTOR® watermark survives) rather than a column of full-bleed images — twelve photos stacked at full width is roughly ten thousand pixels of scrolling before the price. Restyle it freely; the rules are single classes and yours win. What you should not do is leave the gallery unstyled and assume it is a design decision — check the detail page at a real listing before you call it done.

The first photo is eager; the rest are loading="lazy", so a 37-photo listing does not cost several megabytes up front. The wrapper also carries .mk-gallery, so clicking a photo opens the lightbox with no extra authoring.

Want a stage with a thumbnail rail instead of a grid? Add data-gallery="stage" — do not build the swap yourself. The component emits a .mk-listing-detail__stage image followed by the same photos in a .mk-listing-detail__rail (which stays the .mk-gallery), and the runtime wires the rest: a thumb click drives the stage (the active thumb gets .is-active; the wrapper publishes data-mk-index, 1-based, and data-mk-count), and clicking the stage opens the lightbox at that photo — thumb clicks deliberately do not. The stage sits outside the .mk-gallery so the lightbox still counts 12 photos as 12.

The base layer ships the working shape — a full-width stage above a horizontally scrolling strip of 96×72 thumbs — so data-gallery="stage" alone is a usable gallery. Style .mk-listing-detail__stage and .mk-listing-detail__rail to taste; the stage deliberately gets no crop so the watermark survives, so if you give it a fixed aspect ratio, use object-fit: contain.

The gallery element also carries data-mk-photos — how many photos it actually holds, after the data-photos cap and after listings with no imagery are accounted for. Style on it when a one-photo listing should not look like a gallery ([data-mk-photos="1"] .mk-listing-detail__rail { display: none }), rather than assuming every listing came back with a full set.

The value set is closed: anything other than stage is refused at write time, because an unknown value would silently render the flat grid and the page would look finished without its stage. A single-photo listing never splits, whatever the attribute says.

Each listings card row also carries photos[] on /__data/listings, if you want to build your own gallery or a map popover.

A builder's lot can show its floor plans and lot information. Both are opt-in:

<mk-component name="listing-detail" data-include="blueprints,lot"></mk-component>

Either renders nothing on a lot that states none, or on an MLS listing. The selected plan is not in the URL, so an old ?blueprint= link opens on the first plan.

The listing REALTOR® is named, in .mk-listing-detail__agent, above .mk-listing-detail__brokerage. Its own element, so you can restyle, reorder or hide it without touching the brokerage line, which is a display obligation. The element is absent when the feed carries only part of a name, so its presence means it holds a full one. Card rows on /__data/listings carry agentName too.

The share card is the listing's own photo. og:image is set from the bound listing, so a property pasted into a message or a social post previews as that property rather than the site banner. A meta.ogImage set on the template is only a FALLBACK, used when the feed carries no photo — a fixed image would otherwise be the same share card on every property, which is the problem this solves.

meta.ogImage accepts either shape:

{ "assetId": "ast_…" }                                  // a first-party media asset
{ "url": "https://ddfcdn.realtor.ca/listing/…/1.jpg" }  // an allowlisted image host

The url form is limited to https on ddfcdn.realtor.ca or imagedelivery.net — the same two hosts the page CSP allows for img-src. Any other host is rejected (ref.image_host_not_allowed), because unlike an <img>, og:image is never fetched by the page, so the CSP cannot backstop it.

Shape matters. Social cards crop to about 1.91:1, so a tall or extremely wide image arrives as an unrecognisable sliver. The gate warns seo.og_image_aspect when a first-party og:image is outside a loose 0.5–3.0 range. Squares, 4:3, 16:9 and ordinary banners all pass silently; 1200×630 is the target. Only assetId images can be checked — nothing is claimed about a url image.

MLS-number search needs no filter

An MLS number resolves to a listing directly: send the visitor to <detail base>/<MLS number> and the platform redirects to the canonical /…/<mls>/<slug> URL, or answers 410 if the listing has left the feed. So an "MLS #" search box is a navigation, not a query — no filter parameter involved.

Use html rather than building cards from listings. html is rendered by the same component the SSR grid uses, so the CREA display requirements — the per-listing REALTOR.ca text link, brokerage name, watermarked photo, and the collection's one logo + trademark notice — come with it. listings is provided for result counts, sorting and map pins; if you render cards from it yourself, meeting those requirements becomes your page's responsibility.

This endpoint is noindex and invisible to crawlers, which is why the SSR grid still matters: it is what gets indexed.

Some cards legitimately show no price: a listing offered for lease renders its lease figure ($4,248.75/month, or For lease: $20 when the feed states no period), and one with neither a sale price nor a lease figure renders Contact for Price — roughly one listing in ten. Style .mk-listing__price so all three read well.

The agent roster — agent-roster

A brokerage's own REALTORS®, server-rendered from the same DDF feed the listings come from. Use it for a "meet the team" page instead of writing one card per person: the feed carries only active members, so somebody joining or leaving changes the page with no write from you.

<mk-component name="agent-roster" data-sort="role"></mk-component>

That is the whole thing. There is deliberately no attribute that names an office or an agent — the roster is whatever keys the site's own connector row carries, exactly as data-featured="office" resolves from config on listings, and for the same reason: a page able to name any key is a page able to put a competitor's staff under this client's heading.

There is nothing for you to provision. If the site has an officeKey on its connector — which any site with listings does — the roster is scoped from it and the component works as written above. rosterOfficeKeys / rosterMemberKeys exist only for what that key cannot express, and Moseik sets both. Two cases need one:

If you believe you are in either case, say so rather than working around it — and count. data-mk-count against data-mk-total is the only thing that distinguishes a complete roster from a confident-looking fraction of one.

On a site with agent pages: data-source="roster"

When the site has a managed roster collection (a Showroom roster, or the DDF sync behind agent pages), data-source="roster" draws the same cards from it instead of the feed:

<mk-component name="agent-roster" data-source="roster" data-url-filters="page,q"></mk-component>

The grid then lists exactly the agents who have pages, across every office the roster covers, so a multi-office brokerage on Showroom needs no rosterOfficeKeys. Sorting, rotate, paging, search, the initials fallback and profile links work as on the feed. The cards carry name, title, headshot, city, phone and website; never role, designations or languages, which the roster does not hold. data-mk-agents reads roster, and unconfigured on a site with no managed roster. A card for an agent with no CREA id has no data-mk-member. Roster changes re-render the page on the next sync.

Read this before you promise a client a team page

The feed is thinner than it looks, and only two of the gaps are the same on every brokerage:

WantedIn the feed?
Name, roleYes
PhoneUsually — per-member, so sometimes direct and sometimes the office line
HeadshotPer-member. Every member on some brokerages, two-thirds on others
Designations, languagesPer-member, frequently absent
Email addressNo. Not for anyone, ever
Bio, testimonials, listing countNo. Not for anyone, ever

Check what _their_ feed returns before writing copy about it. Everything above the last two rows is per-member, and varies by brokerage: on the first real brokerage this page met, all fifteen members had a direct number (none the office line) and all fifteen had a photo. Render the roster and look. Only email and bio are safe to promise are missing.

So agent-roster gives you a roster that maintains itself, not a finished team page. A site that needs bios, guaranteed headshots, or per-agent email wants authored content — write the cards as HTML, or model the team as a [collection](#collections--lists-that-outlive-the-page) so rows can be edited without a page write. Using both is reasonable: the roster on an index page, an authored profile for the three people who matter most.

A card with no photo renders an initials element instead of an <img>. Do not write CSS that assumes every card has an image — a grid whose rows are sized by .mk-agent__photo collapses on those cards. Size the slot instead, as below. This holds even on a brokerage where every member happens to have a photo: the roster re-renders when somebody joins, and the joiner may not.

.mk-agents__list {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  list-style: none;
  padding: 0;
}
/* Size the SLOT, not the image, so a card with initials matches one with a face. */
.mk-agent__media {
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: 50%;
  background: var(--mk-color-surface-2);
}
.mk-agent__photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.mk-agent__initials {
  font-size: 2rem;
  letter-spacing: 0.05em;
}
/* Most cards would otherwise print "Broker" twice — see below. `:has()` rather
   than deleting the rule, so a member with no JobTitle still shows a role. */
.mk-agent:has(.mk-agent__title) .mk-agent__role {
  display: none;
}

JobTitle and role overlap — show one, hide the other

.mk-agent__title (the feed's JobTitle) and .mk-agent__role (MemberType) are two genuine fields that on a typical brokerage hold the same word. Eleven of fifteen members on the first roster this shipped against rendered:

<p class="mk-agent__name">KRISTIE CAVANAGH</p>
<p class="mk-agent__title">Broker</p>
<p class="mk-agent__role">Broker</p>
<!-- printed twice -->

Both are emitted because they are not redundant in general — role is a closed set worth keying styling off, and JobTitle is free text that is sometimes strictly richer (Broker of Record against a role of Broker) and sometimes a team name. But on most cards they duplicate, which reads as a rendering fault rather than as two fields, so lead with title and hide role behind it — that is what the :has() rule in the CSS above does.

Ordering — data-sort

ValueOrder
name (default)Surname A→Z
roleBrokers and owners first, then salespeople, each alphabetical
feedWhatever order the feed returns, which is stable between renders
rotateSeeded, changes once a UTC day — see below

The platform sorts, not the feed — the Member resource has no sort parameter at all. An unrecognised value is refused at write time rather than quietly defaulting, because a roster under an "Our brokers" heading rendering everyone alphabetically is the failure a sort control cannot survive.

data-limit caps how many render, applied after the sort.

rotate — moving who is above the fold

The other three orderings are stable, so on a 58-agent brokerage the same four people are on the first screen forever. Brokerages treat that as a fairness question between their own agents, and it is a routine request.

rotate orders the roster by a seed that is the UTC date, so it is not random: two renders of the same page on the same day produce identical HTML, which is what keeps snapshots meaningful and a revert exact. Every page carrying a rotate roster is re-rendered shortly after midnight UTC, so the order moves daily with no republish. Each agent has the same chance each day, but it is a shuffle, not a strict turn: someone can miss the first screen several days running.

Don't rotate the _directory_. A 58-person roster is only findable A→Z, and a name search and role filter matter more than placement. The shape that works is a limited rotating strip plus a full name-sorted roster on the same page:

<mk-component name="agent-roster" data-sort="rotate" data-limit="4"></mk-component>
<mk-component name="agent-roster" data-sort="name"></mk-component>
Paging and searching a large directory

data-url-filters="page,q" pages the roster and adds a name search. data-limit is the page size (24 without it). The search runs across the whole roster before paging, so never filter the rendered cards in JavaScript: on a paged directory that searches only the current page. Page 1 is the ordinary cached page; ?page= and ?q= URLs render per request and are noindex.

<form method="get" role="search">
  <label for="q">Find an agent</label>
  <input id="q" name="q" type="search" />
</form>
<mk-component name="agent-roster" data-sort="name" data-url-filters="page,q"></mk-component>

The component renders the pager as nav.mk-agents__pager with a.mk-agents__prev and a.mk-agents__next, and keeps q in both. The wrapper reports data-mk-page and data-mk-total-pages, plus data-mk-query and data-mk-matched during a search, so a script can refill the search box. A search that finds nobody renders data-mk-agents-empty="no-match"; write that copy in CSS or the page. page also pages a listings grid on the same page that opted into it, so keep the two on separate pages.

What this costs, and why the sort is ours

Worth knowing if you are building for a large brokerage, because the shape is unusual and it is forced on us:

The feed cannot sort. sortBy is not a recognised parameter on the Member resource, and passing it does not get ignored — it returns zero results behind a 200. So ordering has to happen after the read, which in turn means the whole roster has to be read before it can be ordered. You cannot take "the first twelve by surname" from an unsorted partial page; you would get twelve names that are sorted and wrong.

So the platform pages through the entire scope — 250 per call, up to 3,000 members — and sorts the complete set. A 150-agent brokerage is one call. A 450-agent brokerage is two. Past 3,000 it stops and sets data-mk-capped, because a partial roster with a sort applied is worse than an honest one.

That 3,000 is a runaway guard, not a limit on how large a brokerage may be — it exists because a mistyped office key matches a large slice of the national member list by substring. If a real brokerage ever hits it, the number should be raised; if you see data-mk-capped on a scope you believe is correct, say so rather than working around it.

None of that cost lands on a visitor. The roster is fetched while the page is being rendered, and the rendered page is stored and served as a static object, so a pageview touches no feed at all. Repeat renders inside five minutes share a cached upstream response. data-limit="12" on a 450-person brokerage still reads all 450 at render time — the price of a correct A–Z — and still serves twelve cards.

Class hooks

HookWhat it is
.mk-agentsThe wrapper. Carries data-mk-agents, data-mk-count, data-mk-total
.mk-agents__listThe <ul>; each child is .mk-agent
.mk-agent__mediaPhoto slot — holds .mk-agent__photo or .mk-agent__initials, inside an a.mk-agent__photo-link when the name links. Size this, not the image — the feed does not say how large a headshot is, so the <img> carries no width/height and reserves no box of its own
.mk-agent__nameDisplay name, assembled from the feed's five name parts. Holds an a.mk-agent__profile to the agent's own page when the site's roster collection has one for them
.mk-agent__titleThe feed's JobTitle — free text. Usually restates __role; see above
.mk-agent__roleSalesperson, Broker, … — a closed set
.mk-agent__designations, .mk-agent__languagesComma-joined; frequently absent
.mk-agent__phone, .mk-agent__tollfreetel: links. __phone is the member's own MemberOfficePhone — often a direct line
.mk-agent__linkAn agent's own site or social profile, suffixed by type (.mk-agent__link--website)

Each .mk-agent carries data-mk-member, the DDF MemberKey — which is the same value a listing carries as ListAgentKey, so it is what you would key an authored bio page off.

When something is wrong, the markup says so

AttributeMeaning
data-mk-agents="unconfigured"No DDF connector on this site, or no roster scope reachable from it. Ask Moseik to set it up; nothing you write in the page fixes it
data-mk-agents="unavailable"The feed read failed. Deliberately renders no list — an empty roster would say "this brokerage has no agents", which is the opposite of what happened
data-mk-agents-emptyThe read succeeded and the brokerage genuinely has nobody on the feed. ="no-match" instead when a ?q= search found nobody
data-mk-mismatched="N"The configured office key is wrong. Fix it
data-mk-total="N"What the feed says the scope holds. Always present, including when it equals data-mk-count. On a paged roster data-mk-count is this page only; compare against data-mk-total-pages × page size. Read the warning below first
data-mk-capped="1"The roster is partial — the scope exceeded 3,000 members so paging stopped. The sort is therefore wrong, not merely short. Almost always a bad scope

data-mk-mismatched catches a failure that is invisible otherwise. DDF matches office and member keys by substring, not exactly — so a truncated key like 524 instead of 52494 returns hundreds of real agents from unrelated brokerages, every one with a real name and headshot, and nothing in the response says anything is wrong. The platform discards rows whose key does not match exactly and reports how many here. A non-zero value means the key was typed wrong when the site was set up.

When data-mk-mismatched is present, data-mk-total over-counts — the same truncated key that admits strangers also inflates the feed's own total (157 for a brokerage of 152). So total − count is not "members missing from the page"; the two numbers describe different populations and subtracting them produces a confident wrong answer. Fix the key, then trust the total.

The multi-office and just-my-team cases are covered under [the scope note above](#the-agent-roster--agent-roster) — both are set up by Moseik rather than anything a page can express, and data-mk-count against data-mk-total is how you would notice you are in one.

A "what is my home worth" seller page — seller-comparables

The highest-intent page on a real-estate site: a visitor types their own address and gets real numbers about their street, in exchange for their contact details. This component is the numbers half, server-rendered.

It is not a valuation and it will not print one. There is no valuation provider behind it, deliberately — so the "Your estimated price" row the design wants is _your own markup and a static teaser_, and it must not be labelled as a figure the platform computed. What this component reports is what is on the market nearby. The visitor's own number comes from a human afterwards, which is the entire point of the lead form.

Before you build: this site must be able to geocode

Address entry runs through /__data/places/autocomplete and /__data/places/geocode — server-side, so no Google key reaches the browser and the page CSP does not move. Both are off unless this site was provisioned with them, and they answer 404 until then, however correct your request is.

Read addressLookup.enabled on GET /v1/site before you write a line of this. The 404 is deliberately blunt — it is a public endpoint in front of a billable account and will not tell a prober why — so that readout is the only thing separating "off for this site" from "wrong URL" from "not deployed". Three answers, three different next actions, and only one of them is "ask a human".

If it is off, stop and ask; you cannot turn it on and neither can the site's own token. Moseik turns it on when the site is set up or later, on request. Building the page anyway gets you a panel stuck in unbound forever, which looks exactly like a page waiting for input.

The three moving parts

The page is one published route that serves two states.

  1. A declared form takes the address and the consent.
  2. /__data/places/geocode turns that address into coordinates and a signed token. You send the visitor to the same route with ?mk_addr=<token>.
  3. That request re-renders this page with the address bound, and the panel fills in server-side.
<mk-component
  name="form"
  data-form-id="lookup"
  data-submit-label="Get my report"
  data-success-message="Looking up your address…"
  data-fields='[
    {"name":"address","label":"Your address","type":"text","required":true}
  ]'
></mk-component>

<mk-component name="seller-comparables" data-radius="0.8"></mk-component>

Declare the form — do not hand-write <form>. The rule from [forms](#declare-forms-as-components--never-hand-write-form) is not relaxed here, and this is where breaking it costs most: the submit URL is minted per form at render, so a hand-written <form> posts to a 404 while looking perfect. Note also that you do not declare a consent field — the component adds one, the name is reserved, and declaring it is rejected at the gate.

Wiring the geocode to the submit

You do not write a submit handler — /__mk/forms.js owns the submit, stores the lead, and confirms in place without navigating. You hang the geocode off the success event instead, from the site's [island](#interactivity--per-site-javascript-islands) — this is ordinary per-site JS shipped with PUT /v1/script, and an inline <script> in the page payload is stripped by the CSP:

// island
let session = null; // one per "visitor started typing an address"
let picked = null; // the placeId of the suggestion they chose, if any

document.addEventListener("mk:form-success", async (e) => {
  if (e.detail.formKey !== "lookup") return;
  // e.target is the form. It is hidden on success but stays in the DOM with its
  // values intact, which is why the address is still readable here — the event
  // detail carries only formKey.
  const address = e.target.elements.address.value;
  // Resolve the place they PICKED when there is one: it is exact, and the same
  // session token makes this the Details call that closes the billing session.
  const query = picked
    ? `placeId=${picked}&session=${session}`
    : `q=${encodeURIComponent(address)}`;
  const r = await fetch(`/__data/places/geocode?${query}`);
  if (!r.ok) return; // 502 — the lookup failed, not "no such address". Say so; don't navigate.
  const { results } = await r.json();
  // An empty list is a real answer — "we could not place that address" — not an
  // error. Say so and leave the visitor on the form; do not navigate.
  if (results[0]?.token) location.href = `?mk_addr=${results[0].token}`;
});

That ordering is why data-success-message is worth setting to something like "Looking up your address…": the confirmation is on screen for the moment between the submit landing and the navigation, and the default "thanks, we'll be in touch" reads as the end of the journey rather than the middle of it.

The lead is captured before the geocode, and that is the point. The submission is already stored by the time this handler runs, so an address that Google cannot place — or a visitor who closes the tab on the results — is still a lead somebody can call.

A visitor with JavaScript off still becomes a lead, but does not get a report. The submit falls back to the native POST and the classic redirect to ?submitted=1, so the address is stored and somebody can call them — but no handler runs, so no geocode happens and the panel stays unbound. That is the right trade in that order, and it is worth knowing before somebody files it as a bug.

If you offer autocomplete suggestions as they type, listen on the declared form's own input (.mk-form__field--address .mk-form__input), debounce it, and thread one session uuid through every keystroke and into the geocode: autocomplete bills per session only when you do, and per keystroke when you don't.

The lead, in two stages

Both stages are ordinary [forms](#custom-forms--the-form-component) — there is nothing special to wire up. The first is the one above: it carries just the address, so an abandoned lookup is still a lead. The second, on the results view, collects name, email, phone and timeframe.

The consent belongs on the first form, beside the address, because that is where the personal information is submitted. An address sent before the visitor agreed to be contacted is a lead nobody can act on.

The token is the only way in

You cannot pass ?lat=&lng= yourself. The panel queries a billable feed on the per-request path, so a page that read raw coordinates from the URL would be an open handle onto that budget for anybody with a URL bar. The token is how the platform knows the coordinates came from a geocode it performed. A missing, forged or expired one is not an error — the route falls through to the published page, which is the form.

The stored copy of the route is always the unbound state, so no visitor's address or neighbourhood prices are ever written into a stored object. A bound results response is not cached anywhere either — it is served Cache-Control: private, no-store, because the document contains the visitor's street address and no shared cache between the platform and their browser should be holding it.

It also carries X-Robots-Tag: noindex, noarchive. Both of those are response headers, not <meta> tags — the same render is served on several hostnames, so these decisions cannot be baked into the markup. Check them with curl -sI 'https://…/your-route?mk_addr=…', not by reading the HTML.

Half the numbers are withheld, and that is the feature

<mk-component name="seller-comparables" data-reveal="count,lowest,dom"></mk-component>
data-revealShows
countHow many active listings are inside the radius
lowestLowest asking price
highestHighest asking price
medianMedian asking price
averageMean asking price
pricePerSqftMedian price per square foot
domMedian days on market
allEvery row above

Omit the attribute and you get count,lowest,dom — the split the production script this replaces used, which is the only version of it with evidence behind it. Everything not named is emitted withheld: the row is in the layout, marked data-mk-withheld, carrying a placeholder.

The placeholder is a deliberately fictitious number, not the real one behind a blur. So filter: blur() here is not security theatre — there is nothing underneath to reveal, and an un-blurred row reads as an obvious dummy rather than as somebody's neighbourhood.

.mk-comparables__stat[data-mk-withheld] .mk-comparables__value {
  filter: blur(5px);
  user-select: none;
}

An unrecognised name in data-reveal is refused at write time, because the failure is silent in both directions: "count,averge" withholds the average it meant to show, and "count,lowset" reveals only the count while looking like it asked for two.

Prefer median over average

House prices are right-skewed: five listings at 300–360k plus one at 4.2M average to 1.1M while the median stays at 340k. Both are emitted because "average price" is what a reader expects that label to mean, but if you are choosing the number, choose the median. The same holds harder for days on market, where one property sitting unsold for three years drags the mean past every real listing; dom is the median for that reason.

Six states, and they are not interchangeable

data-mk-comparables on the wrapper:

ValueMeansSay
unboundNo address yet — the published pageThe form. This is what a crawler sees
okReal figuresThe panel
emptyNothing for sale inside the radius"No active listings within 800 m"
outside-marketThe address is outside the area this site covers"We don't cover that area" — and still show the form
degradedThe feed read failed"We couldn't load this — try again"
unconfiguredThe site has no listing providerNothing. A setup problem on Moseik's side

Never write "0 listings nearby" for degraded. It is a confident wrong answer, and the one a visitor is most likely to believe.

outside-market earns its own copy: "there is nothing for sale near you" and "we do not cover where you live" are different facts, and the second is the one where the lead form is still worth showing.

It fires when the address is in another province than the one the site is scoped to, and — on a site pinned to a city — when the address is in the right province but outside that city. Where the province cannot be read off the address at all (a non-Canadian one), you get empty rather than a guess: telling someone we do not cover them when we do is the expensive mistake.

Radius, and reading back what you got

data-radius is in kilometres, and defaults to 0.8 — or 20 when data-property-category="commercial", because a building is compared with its city and a house with its street. It is clamped to 0.25–25.

Print data-mk-radius-km, not the value you asked for. A page requesting 200 gets 25, and "within 200 km" would then be false.

Set data-property-category (residential, commercial, agriculture) or data-property-type to say what a comparable is. Without one you are comparing a house against every property class in the market — vacant land, parking spaces, industrial units — and the resulting median looks like a perfectly plausible neighbourhood figure.

Attributes to read before you lay the panel out

AttributeWhy you need it
data-mk-countThe sample size, emitted even when count is withheld — three comparables is a thin basis for a table, and this is how you decide not to show one
data-mk-radius-kmThe radius actually searched
data-mk-addressThe address as geocoded — the "we found your property" line, from the server rather than the input, so it survives a reload or a shared link
data-mk-withheldOn the wrapper: comma list of the stats being withheld. On one stat row: "1", which is what you hang the blur off
data-mk-statOn each row — count, lowest, highest, median, average, pricePerSqft, dom. Your styling and ordering hook, and how you find one row to move
data-mk-sampledThe figures rest on the first page of a larger set; days on market is dropped entirely in this case
data-mk-matched-totalWith data-mk-sampled, how many listings matched in total. Over-counts by roughly 27% — the feed counts the bounding box, not the circle inside it, so treat it as an order of magnitude and not a figure to print
data-mk-sample-sizeOn one stat, when its figure rests on fewer rows than the panel counted — price-per-sqft needs a living area, and many rows have none

A stat the feed could not supply is omitted, not withheld — so do not write CSS or copy that assumes a row exists. Withheld means "we have this and are not showing you"; absent means it does not exist, and offering that as a follow-up would be a promise about a number nobody has.

A community's market stats — listing-stats

Averages over the active for-sale listings in one place, rendered on the server and refreshed daily: average, lowest and highest price, total listings, average days on market, average price per sq ft, and the property-type mix.

<mk-component name="listing-stats" data-neighbourhood="Arbor Creek" data-chart="donut"></mk-component>

Scope it with the grid's own attributes — data-neighbourhood, data-city, data-property-type, data-property-category — and the site's market applies last. Put it beside a grid with the same filters and the two describe the same listings.

A named neighbourhood or city is matched exactly, so use the feed's own spelling from GET /__data/listings/facets?fields=CityRegion. A misspelling renders empty.

data-chart="donut" draws the type mix as inline SVG arcs in currentColor at falling opacity; recolour a slice with .mk-listing-stats__slice[data-mk-slice="0"] { stroke: … }. The counts are always in the <ol> too. Prices print compact ($985K, $2.1M), or in full with data-price-format="full"; <data value> holds each exact number.

AttributeMeaning
data-mk-listing-statsok, empty or unconfigured (no listing provider)
data-mk-statOn each row: average-price, lowest-price, highest-price, total-listings, average-days-on-market, average-price-per-sqft
data-mk-countListings matched
data-mk-neighbourhoodThe data-neighbourhood the block was scoped to
data-mk-sampledMore matched than were read: prices describe the newest, and days on market is omitted
data-mk-sample-sizeOn one stat, when it rests on fewer rows than the count — price per sq ft usually does

Custom forms — the form component

When you need fields beyond the presets (first/last name, a dropdown, a date, choices…), use name="form" and declare the fields as a JSON array in data-fields. Delimit the attribute with single quotes so the JSON's double quotes are literal:

<mk-component name="form" data-form-id="apply" data-submit-label="Apply"
  data-fields='[
    {"name":"first","label":"First name","type":"text","required":true},
    {"name":"last","label":"Last name","type":"text","required":true},
    {"name":"email","label":"Email","type":"email","required":true},
    {"name":"role","label":"Role","type":"select","required":true,"options":["Engineering","Design","Ops"]},
    {"name":"start","label":"Available from","type":"date"},
    {"name":"message","label":"Anything else?","type":"textarea"}
  ]'></mk-component>

Each field object: name (required, [a-zA-Z0-9_-], unique, not consent/website), label (defaults to name), type, required (default false), options (for select/radio; on checkbox it makes a multi-select group), placeholder.

Escaping inside data-fields. label, placeholder, options and a hidden field's value are HTML-decoded, so &amp; and &#39; arrive as & and '. Write whichever is convenient — a raw & is not an entity and is left alone. A straight ' still cannot appear raw (it would close the attribute), so &#39; is the way to write one. name is not decoded: it admits no character an entity can produce, so an entity there is rejected rather than repaired.

A required select opens on an empty option, so required can actually fire — otherwise the first choice is preselected and the constraint is satisfied before the visitor touches the control. Give it a placeholder to label that row ("Choose a start time"); it defaults to —.

Field type is one of: text, email, tel, url, number, date, time, textarea, select, radio, checkbox, hidden, file. There is no range type — a from/to is two fields, which is also what the visitor is being asked. A hidden field carries a constant nobody types ({"name":"hotelid","type":"hidden","value":"1315"}) and requires a value — it exists for [off-site actions](#submitting-to-someone-elses-system), where a GET form discards the query string of its own action URL. The one exception is a value your own island writes in the browser before submit: declare "fill": "script" and the value may be empty. Say it explicitly, because only declared fields reach the handler — a script that injects an <input> of its own is silently dropped — and because an empty value on its own stays an error, which is what catches the constant you forgot. A consent checkbox and spam honeypot are added automatically (drop consent with data-consent="false"). A malformed schema is rejected at the gate (gate.form_schema) with the exact problem, and submissions are validated against the same schema before a lead is stored.

File uploads. {"name":"resume","label":"Résumé","type":"file","required":true,"accept":["pdf","doc","docx"]} takes a visitor's file. accept narrows the kinds (pdf, doc, docx, jpg, png, webp, heic; absent means all of them) and "multiple": true takes up to five. Limits are 10 MB a file and 25 MB a submission. The server checks each file's bytes, so a file renamed to .pdf is refused with a message the visitor sees beside the form. Files are stored privately with the lead and deleted after one year. The email, the webhook (files[]) and the dashboard link each file as https://<site>/__files/<token>. That link works for anyone holding it until the file is deleted. To end one early, ask Moseik to delete the file. A file field cannot go on a data-action form (gate.form_offsite_file), and a collection cannot declare one.

Where leads go. By default every form's submissions notify the site-wide recipient (notifyEmail, set with PUT /v1/site). Any form — form, contact-form, or newsletter-form — can override that with data-notify-email="dept@example.com" so _that_ form's leads route to a specific address; an invalid address is rejected at the gate (gate.form_notify_email). Every submission is also stored and readable via GET /v1/leads.

Before launch, email notifications go to a build inbox, not to the addresses above. While a site has no live custom domain, every lead email notification is sent there instead: to Moseik on a site we build, or to the site's own dashboard admins on a site its owner started themselves. It ends by itself the moment the domain starts serving. So set the real client address from the start and leave it alone — there is nothing to change on launch day. GET /v1/site and GET /v1/forms report it at buildRouting. While buildRouting.active is true, a form test returning 200 proves the form stores and sends; it does not prove the client can receive anything, so don't report one as delivering to them on that evidence. Leads are stored either way and GET /v1/leads is unaffected.

Several people can be notified. Both notifyEmail and data-notify-email take a comma-separated list, up to five addresses: data-notify-email="simon@firm.ca,katelynn@firm.ca,info@firm.ca". Each gets their own copy — they do not see each other, and one wrong address cannot cost the others their lead. Beyond five, point it at a distribution list on the client's own mail provider so they can add and remove people without asking anyone.

Reply reaches the person who enquired. The notification carries Reply-To set to the address the visitor submitted — the first field of type email on a declared schema, whatever it is named, or the email field on contact-form / newsletter-form. A form with no email field simply sends none. Nothing to configure, and no second email is sent to the visitor.

Copies, visible or silent. data-notify-cc puts an address in the headers where every recipient can see it; data-notify-bcc does not, which is what an archive mailbox wants. Both take the same comma-separated list and the same five-address cap, both are rejected at the gate for a bad address, and the site-wide equivalents are notifyCc / notifyBcc on PUT /v1/site.

Two things about them are easy to get wrong:

Sending each lead to a different person. Everything above picks a recipient when the page is _rendered_. leadRouting picks one when the form is _submitted_, which is the only way a lead can belong to whoever brought the visitor:

PUT /v1/site
{
  "leadRouting": {
    "field": "attributedAgent",   // the SUBMITTED field carrying the recipient key
    "fallback": "inbox",          // or "round-robin" when nobody is named
    "overrides": { "1520210": { "exclude": true } }
  }
}

The key is opaque — an agent id, a branch code, a territory — and must be an id, never a display name: a fuzzy name match files a real lead with the wrong person and raises no error. field names a field rather than a source, so whatever writes the value (an island, a hidden constant on a branch page, a select the visitor picked) can change without touching this config.

Nothing dead-ends. An unknown key, one that is no longer active, and an empty rotation pool all fall through to the inbox — routing can fail to narrow, but it cannot misroute, because no recipient is ever named in markup. round-robin shares leads out oldest-waited-first, and somebody who has never received one goes first.

Two things worth knowing before you trust it:

Setting up a brokerage's agent roster, start to finish

On a brokerage site — every agent gets their own page, and a lead belongs to whoever brought the visitor — the whole setup is four steps and no waiting. In this order, because the order is the part that is easy to get wrong:

  1. GET /v1/roster. If it answers configured: false, ask the client for the Account ID and Secret under Showroom → Admin → Settings → Developer → Your website, and connect it with PUT /v1/roster-connector. The client confirms the brokerage name it reports; see the API reference. Then you have every agent, each with the creaId that is their routing key, plus the headshot, bio and contact details to build their page from.
  2. PUT /v1/site { leadRouting }. Do this BEFORE building pages. It is what populates the recipient pool — the response tells you how many recipients now exist, and you should see roughly the routable count from step 1, not the total. Use fallback: "inbox". Round-robin on a site whose pages do not carry the attribution field yet scatters every existing enquiry across the whole brokerage.
  3. PATCH /v1/collections/roster { "detailBasePath": "/agents" }. The roster is synced into a collection keyed roster, one row per active agent, slugged by their Showroom profile slug. Step 2 creates it if the 15-minute sweep has not yet. You set only its name and base path; its rows and schema refuse writes (collection.managed), because the next sync would undo them — an agent's details are changed in Showroom.
  4. Publish ONE template at /agents/:slug. It serves every agent, and picks up a Showroom edit within 15 minutes with no rebuild. An agent who leaves answers 410 Gone, and comes back at the same URL if they return. Screenshot it first — the screenshot binds a real agent.
<h1><mk-component name="collection-detail" data-field="displayName"></mk-component></h1>
<mk-component name="collection-detail" data-field="headshot"></mk-component>
<mk-component name="collection-detail" data-field="phoneLink"></mk-component>
<mk-component name="collection-detail" data-field="bio"></mk-component>
<mk-component name="listings" data-agent-key-field="creaId"></mk-component>
<mk-component name="form" data-form-id="agent-contact" data-fields='[
  {"name":"name","label":"Name","type":"text","required":true},
  {"name":"email","label":"Email","type":"email","required":true},
  {"name":"attributedAgent","label":"Agent","type":"hidden","fill":"row","rowField":"creaId"}]'>
</mk-component>

The fields are displayName firstName lastName title headshot primaryCity phone phoneLink email emailLink bio websiteUrl evrylistReferralUrl creaId, plus three recognition slots: recognition1 (the label), recognition1Description and recognition1Image (the badge), then the same for 2 and 3, in Showroom's order. Most agents have none, so hide an empty slot with :empty. phoneLink/emailLink render as tel:/mailto: links labelled with the number and address. An empty field renders nothing, but the element around it stays, so style it with :empty. Never write a claim about an agent that is not a field. Hide creaId and bio in a directory grid with CSS (.mk-collection__field--creaId), and give bio white-space: pre-line to keep its paragraphs. Headshots load from our Cloudflare Images account or CREA's CDN, and get none of the automatic sizing uploaded media gets, so size them in CSS.

data-agent-key-field and "fill": "row" take the key from the agent the URL names, so no page carries a typed-in key that goes stale. Once the template is published, each agent-roster card whose agent has a row links its name to that page; republish the directory page once to pick up the links. Agents with routable: false still get a page; their leads fall back to the inbox. List them for the owner — it is a content gap in Showroom, not something to work around.

The visit belongs to the agent whose page it reached first, on every form. Viewing an /agents/:slug page claims the rest of the visit for that agent: any later form whose leadRouting field arrives empty is routed to them, on any page, with no hidden field and no script. An agent page's own form still names its own agent through "fill": "row". A later agent's page does not take the visit over. Keep a form out of it with data-session-agent="false" — careers, recruiting and join forms are the brokerage's, not the agent's. For page script, the readable cookie mk_agent holds the agent page's path (URL-encoded), e.g. to point the logo at it or hide the brokerage's agent directory.

Then prove it rather than assuming it, from a page OTHER than the agent's own: POST /v1/forms/<a consumer form>/test { "sessionAgent": "<creaId>" } answers with assignee: { key, reason }. reason: "attributed" means the chain works end to end; GET /v1/leads carries the same assignee. "rotation" or no assignee means the key did not match, usually a display name or slug where the creaId belongs.

Two things that are normal and are not faults: leadRecipients is fewer than the roster (only agents with an email address can receive a lead), and most agents have no bio yet (the page still publishes — it is a headshot, contact details and their listings). An agent missing from the roster collection has no usable profile slug in Showroom, or shares one with another agent.

A brokerage without Showroom can have the same collection filled from its DDF roster scope; staff switch it on per site. Steps 3 and 4 are unchanged. Slugs come from the agent's name (jane-doe, or jane-doe-<creaId> when two agents share one) and stay put when a name changes. The feed carries no email or bio, so email, emailLink and bio are always empty and every lead goes to the inbox — skip steps 1 and 2.

Where leads are _forwarded_. Separately from the email, a site can forward submissions to a CRM or webhook. By default every form goes to every destination the site has. To send one form somewhere different, name the destination:

<mk-component name="form" data-form-id="auto-quote" data-lead-destination="underwriter">

The name is all that appears in the page — never a URL and never a secret — and comma-separating several sends to each. A form with no data-lead-destination keeps going to all of them. Read GET /v1/lead-webhooks for the names this site has; a name that is not one of them is rejected at the gate (gate.form_lead_destination), because a form routed at a destination that does not exist submits perfectly and forwards nothing. Configuring the destinations is in the API reference.

Collections — lists that outlive the page

Events, offers, activities, testimonials: lists that change on their own schedule, long after the page is built. Hard-coding them means every change is a page edit, so a resort cannot add next month's trivia night without a developer — and an "Upcoming Events" block with four past dates is worse than no block at all.

A row is data, not markup. It lives behind /v1/collections, so adding one costs no page write, and a finished one stops rendering with nobody touching the site. There is no client-facing form for this — you write rows through the API on the client's behalf, the same way you write everything else. What the client gains is that "add next month's trivia night" stops being a development task.

A collection is a named set of rows with a declared field schema. The schema is the field list a form's data-fields takes, plus image and link — two types a collection has and a form does not:

POST /v1/collections
{ "key": "events", "name": "Upcoming Events",
  "detailBasePath": "/upcoming-events",
  "schema": [
    { "name": "title",  "label": "Title",  "type": "text", "required": true },
    { "name": "starts", "label": "Starts", "type": "date" },
    { "name": "blurb",  "label": "Details","type": "textarea" },
    { "name": "photo",  "label": "Photo",  "type": "image", "altField": "photoAlt" },
    { "name": "photoAlt", "label": "Photo description", "type": "text" },
    { "name": "cta",    "label": "Book now", "type": "link" }
  ] }

Rows go in with POST /v1/collections/events/rows, and the grid renders them:

<mk-component
  name="collection"
  data-collection="events"
  data-featured="true"
  data-limit="4"
  data-sort="starts"
></mk-component>

data-featured="true" shows only the rows the client flagged — a homepage shows four while an index route shows all. data-sort names a declared field, with :desc to reverse. Style .mk-collection, .mk-collection__item and .mk-collection__field--<field name>; never by child position.

One collection, two grids: data-where

data-where="<field>=<value>" narrows a grid to the rows whose declared field matches, so a page can carry two grids of the same collection under two headings:

<h2>Golf Offers</h2>
<mk-component
  name="collection"
  data-collection="offers"
  data-where="category=golf"
></mk-component>

<h2>Everything else</h2>
<mk-component
  name="collection"
  data-collection="offers"
  data-where="category!=golf"
></mk-component>

= and != are the operators; there is no AND/OR, and one comparison per grid. The field is one the schema declares — add a select for the grouping and the client picks from a list — and it filters before data-limit, so a limit counts the rows the grid is for. The comparison ignores case and surrounding spaces.

Reach for this rather than data-featured whenever two pages want different subsets. Featured is one flag per row, shared by every page: flagging a tournament for /golf also puts it on the homepage, and unflagging it to fix the homepage empties /golf.

A clause with no operator is refused at write time, and so is a value outside a select field's declared options — both would render an empty grid that looks finished. A field the collection does not declare renders empty rather than unfiltered, and the write warns: the alternative is the whole collection under a heading written for part of it.

Pictures and links on a card

Every other field type renders as text. image and link render as a real <img> and a real <a> — which is what makes a card a card rather than a list of strings.

TypeThe valueYou get
image/media/<asset id>, optionally ?w=<px><img class="mk-collection__image" data-field="photo">
linka site path, a full https:// url, mailto:, tel:, sms:<a class="mk-collection__cta" data-field="cta">

Both classes are shared by the grid and the detail page, the way .mk-date is, so one stylesheet rule covers a photo wherever it appears.

Don't add width, height, srcset or loading — and don't build the <img> yourself in an island. The render path measures the asset and adds intrinsic dimensions (so the card reserves its space), a srcset ladder capped at the asset's real width, and eager-vs-lazy by position with fetchpriority on the page's LCP image. An island cannot do any of that, and its <img> is invisible to a crawler because it does not exist in the served HTML.

Tell the grid how wide a card image is: data-image-width="364". The number is the widest CSS px box a card image occupies in that grid; the cards then get a matching sizes and the browser picks a rung for the card rather than for the viewport. Measure it — the platform ships no grid rules for .mk-collection, so the column widths are in your stylesheet and nothing else can see them.

Without it a card falls back to sizes="100vw", and a 312px card on a 1440 desktop fetches the 1280 rung. This is the only lever that is per placement: a ?w= cap written into the row's value is shared by every grid AND the detail page, where the same picture is usually full-bleed — so cap for the card and you soften the hero. A value that is not a whole number of pixels from 1 to 4000 is ignored, with a write-time warning.

Alt text and link text come from another field, per row. Put altField on an image (or labelField on a link) naming a declared text field, and that field supplies this row's value:

{ "name": "photo", "type": "image", "altField": "photoAlt" }

That field is then not also rendered as its own div — its value is already on the page, in the alt attribute. With no altField the image gets alt="", i.e. decorative, which is the honest answer for card art beside a title that says the same thing. A link with no labelField uses the field's declared label as its text, so an anchor is never empty.

Both properties are validated, not best-effort: an altField on a field that is not an image, or one naming a field the schema does not declare, is rejected when you write the schema rather than quietly doing nothing.

A link renders as a sibling of .mk-collection__link, not inside it. A card with a detail route is already wrapped in one anchor, and nesting anchors is invalid HTML that a browser fixes by closing the outer one early — which breaks the card link the platform put there. So a link field is hoisted out of the wrapper and sits after it inside .mk-collection__item:

<article class="mk-collection__item" data-slug="the-wyld-trivia">
  <a class="mk-collection__link" href="/upcoming-events/the-wyld-trivia">
    <img class="mk-collection__image" src="/media/ast_…" alt="Trivia night at The Wyld" …/>
    <div class="mk-collection__field mk-collection__field--title">Trivia Night At The Wyld</div>
  </a>
  <a class="mk-collection__cta" href="/the-wyld" data-field="cta">Book now</a>
</article>

It is hoisted whether or not a detail route exists, so publishing the detail template later does not change the shape your CSS is written against. On a detail page nothing wraps the fields, so a link renders in place like any other value.

Already storing the path in a text field? Retype it. PATCH /v1/collections/<key> with the same field names and "type": "image" on that one — the stored values are untouched and start rendering as pictures. The retype is refused if any row holds a value the new type cannot render (a caption, a filename), since those rows would render nothing while still looking fine in the row data; the refusal names the field, counts the rows and shows one offender.

An image value has to be a picture this site uploaded. POST /v1/assets, then store the path it returns. The only off-site values accepted are our own Cloudflare Images account and CREA's photo CDN; any other url is refused — it would load a third party from a page that is otherwise entirely first-party, handing every visitor's IP and referer to someone else — and so is a well-formed asset id that was never uploaded, or one that belongs to a PDF or a video, because those render as a broken image behind a 201.

Editing what is already there

To changeSend
one rowPATCH /v1/collections/events/rows/<id>
the name/schema/routesPATCH /v1/collections/events
remove a rowDELETE /v1/collections/events/rows/<id>
remove the whole thingDELETE /v1/collections/events

A row patch merges data field by field. Send { "data": { "starts": "…" } } to change the start time and nothing else; the other fields are untouched. Clear an optional field by sending it as "". The merged row is validated as a whole, so a patch can never leave a row a fresh write would have rejected. A visibility bound you don't mention keeps its stored value — send null to remove one.

Rows carry authorship. Every row read includes createdBy and updatedBy (null on rows older than the columns), so "who last changed this event" is a read, not a guess. A delete records the full row in the audit trail — data, window, flags, authorship — so an erroneous delete is recoverable: re-POST the same slug with that data, which also clears the 410 the delete leaves behind.

Renaming a row keeps its old URL working. The slug is editable, and the previous URL 301s to the new one, so a link the client already sent out still lands. Deleting a row leaves its URL answering 410 Gone rather than 404 — and re-adding the same slug brings it back to a 200.

Deleting the collection is refused while anything still uses it — a surviving row, a published page or partial or chrome section rendering it, or the detail template at <base>/:slug. The refusal names which, because a page rendering a collection that no longer exists shows an empty section with no error anywhere. So it is a two-step job — clear the thing that holds it, then delete — and ?dryRun=1 tells you which step you are on without writing anything. A mistyped key has nothing holding it and goes on the first try, freeing the slot and releasing its detailBasePath.

Editing the schema is refused if it would drop a field rows still use. The stored values survive, but they stop rendering the moment the field is undeclared — a typo'd field name would blank a column on a live page while the data still looks fine. The refusal names the field and the number of rows affected; re-send with dropFields: ["blurb"] if losing it is what you meant. The collection's key is not editable: pages name it in markup.

Visibility is a window, and it is not the date you display. Each row can carry visibleFrom and visibleUntil:

WindowMeaning
neitheralways visible — the common case
visibleFrom onlyscheduled: appears then, never expires
visibleUntil onlyvisible now, stops rendering then
botha window

A row outside its window is never rendered — you cannot accidentally show a finished event. The row is not deleted, so the client can still fix its dates.

On visibleFrom and visibleUntil, send an instant, not a bare date. A bare "2026-09-30" is rejected on these two fields: it has no timezone, so it would be read as UTC and drop the row six hours early for a client in Saskatchewan. Send an ISO timestamp with an offset, or epoch milliseconds, converted using the site's timezone. This is about the WINDOW only — a date field you display holds the bare date, and giving one an ISO timestamp stops it rendering as a date at all (below).

Keep this separate from whatever date the row _shows_. An event's JUN 25 – SEP 30 is content in its starts field; visibleUntil is how long it keeps appearing. A resort that wants events listed for a week afterwards sets visibleUntil a week later, and the displayed date is untouched.

A date field is stored as 2026-06-25 and rendered as a date. You get:

<time class="mk-date" datetime="2026-06-25">
  <span class="mk-date__month">Jun</span><span class="mk-date__sep"> </span
  ><span class="mk-date__day">25</span><span class="mk-date__sep">, </span
  ><span class="mk-date__year">2026</span>
</time>

The parts come in the site locale's own order (en-CA "Jun 25, 2026", fr-CA "25 juin 2026"), so the JUN-over-25 chip on a resort card is CSS — .mk-date { display: grid } plus .mk-date__sep, .mk-date__year { display: none }. Every literal sits in its own .mk-date__sep so you can hide the punctuation; a bare text node would become an anonymous grid item you cannot select. Don't store a formatted string instead — it cannot sort, and it cannot expire. A range is two date fields, with the dash in your own markup. Text a client typed into a date field ("Every Friday") renders as itself.

Which is also what happens to a timestamp. "2026-06-25T19:00:00-06:00" is not a bare date, so it is served as that literal string — no <time>, no datetime, no .mk-date__month / __day to style, and a chip built from the markup above renders raw ISO text on every card. The write is accepted (a date field holds text by design) but it returns a collection.date_not_a_date warning naming the field. Store the bare 2026-06-25 and put a time of day in its own field. The instant rule above is for visibleFrom / visibleUntil only.

A time field is stored as 19:00 and rendered the same way, as <time class="mk-time" datetime="19:00"> with a .mk-time__hour, .mk-time__minute and .mk-time__sep per part. The site locale picks the clock — en-CA reads "7:00 p.m." and adds a .mk-time__dayPeriod, de-DE reads "19:00" and has none, so style the dayPeriod as optional. Seconds are accepted and not displayed. A whole timestamp in a time field warns as collection.time_not_a_time and renders as itself, the same as the date case above; so does text a client typed ("Doors at 7").

You do not have to re-render. Adding, editing or removing a row regenerates the pages carrying that collection, and a sweep every half hour regenerates them again when a row crosses a visibility boundary — which is what makes an event disappear the day after it ends with nobody touching the site.

A page per row

Give the collection a detailBasePath and publish one page whose route is that base plus /:slug. The router binds the row the URL names, so that single page serves every row — 31 events are one page, not 31.

POST /v1/pages { "route": "/upcoming-events/:slug", "title": "{{row.title}} | Elk Ridge", … }
<article class="event">
  <h1><mk-component name="collection-detail" data-field="title"></mk-component></h1>
  <p class="event__when">
    <mk-component name="collection-detail" data-field="starts"></mk-component>
  </p>
  <mk-component name="collection-detail" data-field="blurb"></mk-component>
</article>

data-field emits that field's value and nothing else — no wrapper, no class — so the detail page is your markup, not ours. Drop data-field to emit every declared field as .mk-collection-detail__field--<field name> instead, which is quicker when CSS is doing all the work.

Put {{row.<field>}} in the page title and meta description. One page row serving every event means every event inherits that page's <title> unless you do — the same duplicate-content bug listing detail routes hit, and the SEO gate cannot see through a pattern route. An untokenised title is replaced by a derived per-row one rather than served as-is; substitution works in the title and description only, never in page HTML.

The share card is the row's own picture, automatically. If the collection has an image field, the first one holding a value becomes this page's og:image — so an event pasted into a text message shows that event, not the site's banner. It outranks a fixed meta.ogImage on the detail template, which becomes the fallback for a row that left the field blank. There is nothing to set. The ?w= cap is dropped — a scraper wants the full-size image.

The rest follows from the visibility window, with nothing to maintain:

The rowIts URL
visiblerenders, and is in sitemap.xml
expired (visibleUntil)410 Gone, and out of the sitemap
scheduled (visibleFrom)404 until the window opens

Cards in a grid link to these URLs automatically — but only once the /:slug page is published, so a card never points at a page that does not exist yet. One base path serves one collection; a second collection asking for the same one is refused.

Blog posts are not collections. A post wants its own <title>, meta description, OG image, Article JSON-LD and version history — everything a page already has. Make posts pages, and list them with page-list below.

Blog posts, news, case studies — the page-list component

A post is a page. To turn a page into a post, give it a publication date:

PATCH /v1/pages/pg_…   { "meta": { "publishedAt": "2026-08-16" } }

That one field is what puts the page in the list and in the feed, and it is what they sort by. Then put the component on your index page:

<mk-component name="page-list" data-under="/blog/"></mk-component>

It lists the site's own published pages under that path, newest first. Do not hand-write an index page instead — a hand-written one is correct the day you write it and silently stale the day the next post goes live, and nothing on the page looks wrong when it happens.

Pages under that path with no publishedAt never appear — a landing page, an archive stub, or an index that sits inside its own path. There is no second flag to unset.

AttributeDoes
data-underPath prefix to list, e.g. /blog/. Required.
data-limitPosts per page. Default 10, max 50.
data-ordernewest (default) or oldest.
data-headingElement for each title: h2 (default), h3, or none for a span.
data-url-filtersSet to page to turn on ?page=N.

Emitted markup — no styling, only hooks, like every other component:

<div class="mk-page-list" data-under="/blog/" data-mk-page="1" data-mk-total="23" data-mk-total-pages="3">
  <article class="mk-page-list__item">
    <a class="mk-page-list__link" href="/blog/spring-market">
      <img class="mk-page-list__image" src="/media/ast_…" alt="" loading="lazy" />
      <h2 class="mk-page-list__title">Spring market update</h2>
    </a>
    <time class="mk-date" datetime="2026-08-16">…</time>
    <p class="mk-page-list__summary">…</p>
  </article>
  <nav class="mk-page-list__pager" aria-label="Pagination">
    <a class="mk-page-list__next" rel="next" href="/blog?page=2">Next</a>
  </nav>
</div>

The card's title, summary and image come from the page's own title, meta.description and meta.ogImage — there is no second set of fields to keep in step. A post with noindex is left out. The date renders as the same <time class="mk-date"> with per-part spans a collection date field uses, so one rule styles dates everywhere.

Pagination. With data-url-filters="page", page 1 stays the cached static page and ?page=2 renders live and noindex — so pagination never forks your canonical URLs. The component emits its own rel="prev" / rel="next" anchors. Every post is in sitemap.xml regardless of which page of the index it lands on, so nothing depends on a crawler walking the pager.

The pager links to the index's own route, not to data-under. The two are usually the same path — an index at /blog listing /blog/ — but need not be: an index routed /our-office-listings that lists /office-listing/ gets /our-office-listings?page=2. data-under selects content and stays on the wrapper as a styling hook; it is never a link. A ?page= past the last page serves the last page (and says so in data-mk-page) rather than an empty list.

The feed comes free. As soon as one page has a publishedAt, the site serves /feed.xml and every page advertises it with a <link rel="alternate">. You do not author it and there is no switch — a site with no posts has no feed and no link, which is the honest state.

Article structured data is still yours to add, per post, via PATCH /v1/pages/:id { "meta": { "schema": … } }. The shell cannot infer an author or a headline that differs from the title.

Writing a post

When the owner asks for a new post, a case study or a portfolio piece:

  1. Ask for the topic, the key points and an image. Don't ask what the site already answers.
  2. Find the index: the published page carrying a page-list, and its data-under. The post goes under that path (/blog/<short-hyphenated-slug>), never at the root.
  3. Copy the markup of the newest post under that path, so the layout matches.
  4. Create the page with meta.publishedAt (today), meta.description and meta.ogImage. Those three make up its card on the index.
  5. Link to two or three of the site's service pages or posts where the text fits, and end with a related-posts page-list if the other posts have one.
  6. Send the owner the live URL.

A post written outside every index's path, or without a date, comes back with a post.outside_index, post.missing_published_at or post.not_listed warning naming the route it belongs at. A route never changes, so to move a post, create it at the right route and archive the old one with a redirect to it.

Partials — fragments reused across pages

A partial is a named HTML fragment referenced from many pages and inlined at render. Edit it once; every referencing page updates.

PUT  /v1/partials/cta   { "html": "<section class=\"cta\">…</section>" }
<!-- in any page payload -->
<mk-partial ref="cta"></mk-partial>

Partials publish → the site re-renders. A reference to a missing/unpublished partial is rejected at the gate; there is no partial-in-partial nesting in v1; unpublishing a partial that a published page still references is blocked (DELETE /v1/partials/:ref → 409).

Chrome — site-wide header, footer, announcement

Chrome is the markup that appears on every page. Each of header, footer, announcement is a single per-tenant fragment; the renderer stitches them around each page (announcement + header above the body, footer below), and publishing one re-renders the whole site.

GET  /v1/chrome                              → { header, footer, announcement }
PUT  /v1/chrome/header  { "html": "…", "css"?: "…" }
POST /v1/chrome/header/revert { "toVersionId": "ver_…" }

Chrome can use everything a page can — components, partials, interactive hooks (a .mk-nav dropdown belongs here), and its own CSS (which applies site-wide). Build the header and footer first on a new site.

Which reuse mechanism?

The platform attribution badge

Every served page ends with a small "Created on Moseik" badge — a one-line, centered link rendered by the platform after your footer, outside every surface you author. It is part of the page shell, like the <head>: you will never see it in a payload, you don't add it, and you can't remove or restyle it. Design your footer as if the badge weren't there, and it will look right.

It carries its own colour pair — your theme's --color-text on --color-background — rather than inheriting from body, so it blends in on a site whose page background is its theme background and stays legible on one that paints body something else. To make it disappear into a section whose background differs from your theme's, set the theme tokens to match.

Its identifier (mk-attribution) is reserved: any authored HTML, CSS, or JS containing that string is refused at the write gate with sanitize.reserved_attribution (or the same code from PUT /v1/script). A rule targeting it, a counterfeit copy, or a script that removes it all fail at write time.

The badge comes off on Grid and above, or wherever staff switch it off — either one is enough, and neither is yours to set. GET /v1/site → attribution reports whether this site carries it and which of the two took it off (planIncludes / staffOverride). Read that before answering a client who asks: the answer is a plan change or a staff request, never a write, and the reserved token refuses the write regardless.

Interactivity — per-site JavaScript (islands)

Yes, you can add JavaScript. Interactive behavior — tabbed/click selectors, carousels, scroll-triggered reveals, accordions, sticky headers, counters — is fully supported through per-site JS. Reach for it whenever the source has interactivity; you do not have to render those sections static.

PUT  /v1/script   { "js": "…vanilla JS…" }
GET  /v1/script                          → current js + version
POST /v1/script/revert { "toVersionId": "ver_…" }

Your JS is versioned, size-budgeted (≤ 512 KB), bundled, and served first-party at /__mk/site.js; every page loads it (deferred) while a script is published. It runs under the strict CSP — it can manipulate the DOM freely but cannot load a third-party script or reach an off-site origin, so write self-contained vanilla JS. The pattern is progressive enhancement: author normal markup with class hooks, then activate it from the island.

If something is blocked, we already know. Every page reports CSP violations back to the platform, so a refused script or fetch is visible to us rather than only to the visitor's console. So you do not need to report "this was blocked" — and you should not work around a block you do not understand. Check GET /v1/site → csp.effective first: it is often the difference between "the platform forbids this" and "this site has not enabled it".

// a tabbed selector (like a "solutions" switcher): markup has
// <div class="tabs"><button data-tab="01">…</button>…<section data-panel="01">…
document.querySelectorAll(".tabs").forEach((tabs) => {
  const show = (id) => {
    tabs.querySelectorAll("[data-panel]").forEach((p) => (p.hidden = p.dataset.panel !== id));
    tabs.querySelectorAll("[data-tab]").forEach((b) => b.classList.toggle("is-active", b.dataset.tab === id));
  };
  tabs.querySelectorAll("[data-tab]").forEach((b) => b.addEventListener("click", () => show(b.dataset.tab)));
});

// scroll-triggered reveal / scale
const io = new IntersectionObserver((es) =>
  es.forEach((e) => e.target.classList.toggle("in-view", e.isIntersecting)));
document.querySelectorAll(".reveal").forEach((el) => io.observe(el));

Calculators & configurators — form controls + an island

For any configurator, author real form controls (input, select, output, …) and compute in the browser from the island. The controls are inert on their own — no oninput=, no submit — so read them and write the result from your JS:

<!-- payload html -->
<div class="calc">
  <label>Length <input type="number" id="len" value="12" min="1"></label>
  <label>Width <input type="number" id="wid" value="10" min="1"></label>
  <output id="area">—</output>
</div>
// island
const calc = document.querySelector(".calc");
if (calc) {
  const $ = (id) => calc.querySelector("#" + id);
  const update = () => {
    $("area").textContent = (+$("len").value * +$("wid").value).toFixed(1) + " sq ft";
  };
  calc.addEventListener("input", update);
  update();
}

Everything runs client-side; nothing is submitted. A raw <form> is fine too, but it can only submit first-party (CSP form-action 'self') — for lead capture that must reach your inbox/CRM, use the form component instead (above).

Modals — use <dialog>, do not build one out of a <div>

<dialog> is allowed, and it is the only correct way to write a modal. Opening it with showModal() gives you, from the browser and for free:

A <div role="dialog" aria-modal="true"> gives you none of that, and hand-rolling it is where modals go wrong — the usual result is a dialog that traps focus but does not restore it, or one where Esc does nothing. If you find yourself writing focus management in your island, you are re-implementing this element.

Write the markup in the payload; open and close it from the island.

<!-- payload html -->
<button type="button" data-open-tour>Book a tour</button>
<dialog id="tour">
  <h2>Book a tour</h2>
  <mk-component name="form" data-form="tour" data-fields="…"></mk-component>
  <button type="button" data-close-tour>Close</button>
</dialog>
// island
const tour = document.getElementById("tour");
document.querySelector("[data-open-tour]")?.addEventListener("click", () => tour.showModal());
tour?.querySelector("[data-close-tour]")?.addEventListener("click", () => tour.close());
/* page css or the site stylesheet */
#tour::backdrop {
  background: rgb(0 0 0 / 60%);
}

Only the open attribute is allowed on it. Writing <dialog open> gives you the non-modal dialog — visible, but with no focus trap and no backdrop — so if you want the modal behaviour, leave it closed in the markup and call showModal(). A dialog with no open attribute is hidden before your island runs, which is what you want: nothing flashes on screen during load.

Mortgage & land-transfer tax — do NOT hand-roll these

> An earlier version of this page showed a mortgage example using rate / 100 / 12. > That is the US convention and it is wrong in Canada. If you copied it, or copied a > calculator from another site, replace the math with the functions below.

Canadian mortgage arithmetic has three traps, and every one produces a number that looks entirely plausible:

Wrong (common)Correct
CompoundingannualRate / 12 — US monthlySemi-annual: (1 + r/2)^(2/n) − 1
CMHC premium base% of the purchase price% of the loan amount
CMHC premiumadded to a "total" lineadded to principal and amortized

On a $749,000 purchase at 4.49% over 25 years with 10% down, those three together overstate the premium by $2,322 and understate the payment by $99.72 a month for 25 years. Land-transfer tax is worse: Toronto's municipal tax stops matching the province's above $2M, the first-time-buyer rebates are capped at the tax owed (they are not payouts), Toronto has its own $4,475 rebate, and the Non-Resident Speculation Tax moved to 25% province-wide in 2022.

So the platform does the math. Add data-mk-finance to any element on the page and you get two functions:

<!-- payload html — `data-mk-finance` is the whole opt-in -->
<section class="mortgage" data-mk-finance>
  <label>Price <input type="number" id="price" value="749000" step="1000"></label>
  <label>Down payment <input type="number" id="down" value="74900" step="1000"></label>
  <label>Rate % <input type="number" id="rate" value="4.49" step="0.01"></label>
  <label>Amortization
    <select id="years"><option>25</option><option>30</option></select>
  </label>
  <output id="payment">—</output>
  <p class="mortgage__meta"></p>
</section>
// island
const el = document.querySelector(".mortgage");
if (el) {
  const $ = (id) => el.querySelector("#" + id);
  const update = () => {
    const r = window.mk.mortgage({
      price: +$("price").value,
      downPayment: +$("down").value,
      annualRate: +$("rate").value, // PERCENT — 4.49, not 0.0449
      amortizationYears: +$("years").value,
      province: "ON",
    });
    if (!r.ok) {
      $("payment").textContent = "—";
      el.querySelector(".mortgage__meta").textContent = r.errors[0];
      return;
    }
    $("payment").textContent = "$" + r.payment.toFixed(2) + "/mo";
    el.querySelector(".mortgage__meta").textContent =
      `CMHC premium $${r.cmhcPremium.toFixed(2)} (financed) · rates as of ${r.ratesEffective}`;
  };
  el.addEventListener("input", update);
  update();
}

Payment frequency, and the two amortization numbers

paymentFrequency accepts exactly: monthly (the default), semi-monthly, bi-weekly, weekly, accelerated-bi-weekly, accelerated-weekly.

An accelerated schedule is the monthly payment divided in two (or four) and paid every two weeks (or week), so you pay a little more each year and clear the mortgage early. That means the result carries two different amortizations, and picking the wrong one silently understates the whole point of choosing it:

fieldmeans
effectiveAmortizationYearshow long it actually takes — 21.69 where you asked for 25
amortizationYearsthe same number as above (kept for existing callers)
scheduledAmortizationYearsthe amortization you asked for, echoed back — always 25 here

Render effectiveAmortizationYears for "pays off about 3 years sooner". Note amortizationYears is also the name of an _input_, which is why the unambiguous field exists — pass 25, read 21.69, and nothing is wrong.

totalInterest already reflects the faster payoff, so it agrees with effectiveAmortizationYears and not with scheduledAmortizationYears.

The rate is yours. annualRate is an argument and the platform has no rate table — that is your mortgage partner's pricing. Render whichever of their products you like and pass the selected rate in. If they publish a feed, put it behind a [connector](#live-data--the-__data-proxy) rather than hard-coding it; if they don't, a partial keeps it in one editable place. Name the lender and the date next to any rate you display.

Read ok first. On ok: false there is deliberately no payment field — so a bad input can't print a number for a mortgage that cannot exist. errors[] says what's wrong (and minimumDownPayment comes back even on failure, so you can name the shortfall). notes[] carries things worth surfacing, like the fact that provincial sales tax on the CMHC premium is payable at closing and cannot be financed.

window.mk.landTransferTax({ price, province: "ON", municipality: "toronto", firstTimeBuyer, foreignBuyer }) returns total plus every component separately (provincial, municipal, provincialRebate, municipalRebate, nrst) so you can itemise. Ontario only today — an unsupported province is refused rather than approximated, because a figure computed from the wrong province's brackets is indistinguishable from a correct one.

Print ratesEffective. Every result carries the date of the tables that produced it. A stale central table looks authoritative, and that date is the only thing that lets a reader — or us — notice.

These are estimates, not advice. Put your own disclaimer next to the output.

Style the states (.is-active, .in-view, [hidden]) in your stylesheet. Because the island runs in a real browser under the CSP, the screenshot endpoint captures the _settled_ state — drive interactions in your own testing.

Google reviews — the google-reviews component

Reviews are fetched on the server and rendered into the page:

<mk-component name="google-reviews"></mk-component>

Do not reach for a review widget. A vendor script can't run under the page CSP, an embed can't be styled to match the site, and either way the review text arrives after hydration and no crawler ever sees it. Rendering server-side puts the words in the document.

The place is named, never pasted. Site config holds the Place IDs under names:

{ "places": { "default": "ChIJ…", "dartmouth": "ChIJ…" } }

so a page writes data-place="dartmouth", or nothing at all for default. Same rule as data-featured — a Place ID in markup goes stale silently, and a page able to name any id is a page able to show a competitor's reviews under your client's heading. GET /v1/site → reviews.places lists the names you may use.

Don't ask the client for a Place ID. It appears on no dashboard and not in the URL of the business's own Maps listing, so asking produces a CID or a 0x…:0x… hex pair that looks right and isn't. Moseik sets the places for a site: ask, giving each location's business name and town, and have a human confirm the address of what was chosen — a franchise name plus a city is not unique, and a wrong pick shows another business's reviews under your client's name.

Google returns at most five reviews and cannot page. That is Google's limit, not ours, so "all our reviews" is not available at any price — know that before you promise it to a client. The rating and count are different: those are Google's across every review, so the aggregate is real even though the list is a sample.

AttributeEffect
data-placewhich configured place (default: default)
data-limithow many reviews to render, 1–5 (default 5)
data-min-ratinghide reviews below N stars — the write gate warns, see below

Hooks: .mk-reviews, .mk-reviews__rating, .mk-reviews__count, .mk-reviews__list, .mk-reviews__attribution, and per review .mk-review__photo, __author, __stars, __time, __text. Each .mk-review carries data-mk-rating, so draw your own stars from it rather than styling the text ones. The block carries data-mk-place — the place NAME it resolved (default, or whichever key you passed to data-place), never the Place ID — which lets a page with two locations label each block by which one it shows.

The reviewer's name and the attribution line are display obligations. Restyle them freely; do not remove them. Hiding .mk-review__author with CSS breaks the same rule as hiding .mk-listing__realtor.

Linking to the client's Google page

Never paste a Place ID, a CID or a maps.google.com link into markup to build a "see all our reviews" button. A pasted identifier is exactly what naming the place avoids, and a CID looks compliant while carrying the same drift.

Two ways to get the link with no id:

GET /v1/site → reviews.mapsUrls reports the same URL per place name, so a build script can assert a link still points where it should. A place is absent from it until a page has rendered it — the URL comes from the cached lookup, not a fresh call.

Nothing renders when the lookup fails — no empty review block. "This business has no reviews" and "we couldn't reach Google" look identical to a visitor and only one of them is true, and printing the wrong one on a client's own site is worse than printing nothing. Style .mk-reviews--unavailable if you want a placeholder; it carries data-mk-reviews="unavailable" (or "unconfigured" when no place is set up).

On data-min-rating. It exists because clients ask for it, and the gate warns every time it is used. The rating and count beside the list are unfiltered, so a five-star-only list under a 4.2 average tells a visitor something the numbers don't support — and Google's terms speak to misrepresenting review content. The component publishes data-mk-filtered with the number it hid, so the choice is visible in the markup.

Neighborhood stats — the neighborhood-stats component

Real figures for the area a community page is about, fetched and rendered on the server — so they are in the HTML a crawler sees, and the page contacts no new origin. That is why this is a component rather than a fetch from your island: stats that arrive after hydration are invisible to search, which is the only reason a community page exists, and connect-src 'self' would block the request anyway.

<mk-component
  name="neighborhood-stats"
  data-boundary-id="63334852ee00b80009d9442c"
  data-heading="Cathedral by the numbers"
></mk-component>

Naming the area — two ways, never both

All three area components (neighborhood-stats, community-map, market-trends) pick their area the same way:

AttributeUse onMeans
data-boundary-id="6333…"a community pagethis exact area
data-boundary="listing"a listing detail pagethe area containing this listing

Get an id from GET /v1/areas?name=Cathedral&lat=…&lng=… (MCP find_areas); ids are opaque and are not guessable.

A page carrying neighborhood-stats or market-trends is re-rendered once a day, so its figures stay current with no republish. If LiveBy cannot be read then, the live page is kept.

On a listing detail page you must use data-boundary="listing". The template serves every property, so a literal id would print one neighborhood's figures on all of them — correctly formatted and wrong. The platform resolves the area from the listing's own coordinates instead.

That resolution is containment, not proximity. The area whose shape the property sits inside, confirmed by point-in-polygon — not the nearest one, which for Cathedral's own centre would be Albert Park.

Two things follow that you should design for:

Writing both attributes is an author error and renders ambiguous-binding rather than a guess — on a listing template a stale explicit id would silently win on every page.

Unlike data-place on google-reviews, the id lives in the markup. A boundary is public geography — there is no-one to impersonate, and every site may legitimately write about any neighborhood, so a config indirection would buy nothing but a second write per page. The staleness risk is answered directly instead: the block renders the area's own name and echoes it in data-mk-area, so a wrong id shows a wrong _name_ in the heading you are already reading, not silently wrong numbers. Check it.

AttributeDefaultWhat it does
data-boundary-id—required; the area
data-fieldssix common statspick and order stats from the closed set below
data-lifestyles0add the top N lifestyle categories, max 6
data-headingnonerenders an <h2> inside the block

Fields: population, medianAge, households, averageHouseholdSize, medianIncome, medianHomeValue, medianRent, ownerOccupiedShare, activeListings, medianListPrice, medianDaysOnSite. A name outside that set is ignored.

A figure the provider does not have is omitted, never printed as zero. The provider answers 200 with zeros for whole countries it does not cover — Canadian areas have no sold data and no schools — so this distinction is the difference between a true page and a false one. Dropped stats are named in data-mk-missing, so verify and you can see what went.

Money renders in the area's currency, and in none at all when the country is unknown — better a bare number than one confidently labelled the wrong dollar.

Style via .mk-area, .mk-area__stats / __stat / __label / __value / __lifestyles / __source. The block is a <dl>, so dt/dd are your hooks too.

These attributes are emitted for you to read — in CSS, in an island, or when checking a page:

AttributeOnWhat it carries
data-mk-areathe sectionthe area's own name — check this against the page you meant
data-mk-layerthe sectionneighborhood, city, postal-code, …
data-mk-missingthe sectionstats dropped because the provider had no figure; absent when none
data-mk-stateach statthe field name, so you can style or hide one
data-mk-percentileeach lifestylethe percentile as a number, for drawing your own bar
data-mk-area-statsthe markerwhy nothing rendered — see below

Nothing renders when the lookup fails — an empty stats block reads as "nobody lives here". The marker carries data-mk-area-stats="unavailable", "unconfigured" (the deployment has no provider key) or "no-boundary" (you left the attribute off).

The source line carries the census vintage and is a display obligation: restyle it, do not remove it.

Community map — the community-map component

The area's boundary drawn on a map, as a same-origin image. That is the whole trick: the platform proxies the rendered map at /__data/areas/<id>/map.png, so img-src 'self' already covers it, the provider key never reaches the browser, and this feature needs no CSP change at all.

<mk-component name="community-map" data-boundary="listing" data-width="640" data-height="400"></mk-component>

It is a picture, not a pannable map — deliberately. map and listings-map exist for interactive work and cost an iframe each; a community page wants "here is where this is", which lazy-loads and costs nothing below the fold.

data-width / data-height (defaults 640×400) are real img attributes, so the space is reserved and the page does not shift. data-alt overrides the alt text, which otherwise names the area — the second place a wrong boundary shows up in words a human reads. Hooks: data-mk-area, data-mk-layer on the figure; data-mk-area-map on the marker when nothing renders. Style .mk-areamap / .mk-areamap__img.

Market trends — the market-trends component

Monthly closings, median sale price and days on market.

<mk-component name="market-trends" data-boundary-id="…" data-months="12" data-chart="sparkline"></mk-component>

Check the page before you promise this section to a client. It is the one area component that often cannot render, and the reason is coverage, not configuration:

So the component compares sold volume against standing inventory and renders data-mk-trend="not-covered" instead of drawing it. A chart is the most persuasive way to publish a wrong number.

data-months is the window (3–36, default 12). data-metric is price (default) or volume. data-chart="sparkline" adds an inline SVG line — inline because script-src 'self' means no charting library can run, and the figures stay in an <ol> either way so a crawler and a screen reader both get them. data-heading adds an <h2>.

Hooks: data-mk-metric and data-mk-closings-total on the section, data-mk-period and data-mk-closings on each point. Style .mk-trend, .mk-trend__points / __point / __period / __value / __spark.

Address autocomplete and geocoding

Two same-origin endpoints, so no Google key reaches the browser and the CSP is untouched:

GET /__data/places/autocomplete?q=100+Main&session=<uuid>
    → { "suggestions": [ { "placeId": "ChIJ…", "text": "100 Main St, Halifax, NS" } ] }

GET /__data/places/geocode?placeId=ChIJ…&session=<uuid>     ← prefer this
GET /__data/places/geocode?q=100+Main+St+Halifax
    → { "results": [ { "token": "v1.…", "lat": 44.65, "lng": -63.57,
                       "address": "…", "placeId": "ChIJ…" } ] }

token is the field you came for. It is a signed statement that we geocoded this address, and it is the only way to bind an address to [seller-comparables](#a-what-is-my-home-worth-seller-page--seller-comparables) — send the visitor to your results route with ?mk_addr=<token>. The lat/lng beside it are informational: you cannot pass coordinates to the panel yourself, so they are not the usable part.

Resolve by placeId, not by text, whenever the visitor picked a suggestion. It is the exact place they chose rather than a re-parse of the string, and passing the same session makes this the Details call that closes the autocomplete billing session — so the whole lookup bills as one session instead of as a fresh geocode. q is for the case where there was no suggestion to pick.

Don't load the Places JS library instead. connect-src is pinned to 'self', so a third-party fetch doesn't merely break a rule — it fails to load; and a browser Maps key is public by construction.

session is REQUIRED on autocomplete, and the same value must ride every keystroke of one lookup and then the geocode that resolves the chosen placeId. Autocomplete bills per _session_, not per keystroke, so without the token the bill is wrong by an order of magnitude — which is why a call without one is a 400 rather than a silent overspend. Generate one token per "user started typing an address" and discard it once the geocode comes back. geocode does not require one.

Autocomplete needs at least 3 characters. One or two match most of a country, so a shorter query is refused with a 400 rather than billed for suggestions that could not have helped. geocode has no such floor.

Debounce. There is a per-IP rate limit and you will hit it firing on every keypress.

Off by default. It is a public endpoint onto a billable account, so a site has to be opted in; until then the route 404s. GET /v1/site → addressLookup.enabled tells you whether this site has it (addressLookup also carries usage, and a reason when it is off). If you need it, ask — it is one config call, not a code change.

results: [] from geocode means "we could not place that address" and is a real answer — tell the visitor to check what they typed. A 502 means the lookup failed and the body says how; retry. Those are the only two shapes: every response carries X-Mk-Places-Path naming the resolver that answered (details, geocode, text-search), so an empty answer is provably one a real lookup returned. Nothing is cached, so don't design around a warm second call.

Live data — the /__data/ proxy

To render data from an external source (e.g. a Google Sheet), your island cannot fetch that source directly — the CSP blocks off-site requests, and putting an API key in page JS would leak it. Instead fetch the platform's same-origin data proxy, which calls the source server-side (credentials stay on the server) and returns raw JSON. You own the markup — render it as a table, cards, tabs, whatever; the platform imposes no shape.

GET /__data/sheet?sheetId=<id>&tabName=<tab>   → the source's JSON rows
// author an empty container in the page, fill it from the island
fetch("/__data/sheet?sheetId=" + el.dataset.sheetId + "&tabName=Pricing")
  .then((r) => r.json())
  .then((data) => renderHoweverYouWant(el, data))   // your markup + styling
  .catch(() => showFallback(el));                    // source down → graceful fallback

The response is short-cached and noindex (it's data, not a page), so this content is client-rendered — fine when SEO isn't required for it. sheetId/tabName are supplied by the page; the source URL + key are configured on the platform.

Freshness — check it, don't assume it

If the source is unreachable, the proxy still answers 200 with the last data it fetched successfully, so an outage doesn't blank your page. Every response says which it is:

HeaderMeaning
X-Data-Stale: 0Live — fetched from the source just now.
X-Data-Stale: 1The source was unreachable; this is the last-known-good copy.
X-Data-Age-SecondsStale responses only — how old the copy is.
X-Data-Fetched-AtStale responses only — ISO timestamp of the last good fetch.

A 502 means the source is unreachable _and_ we have no cached copy to fall back on (nothing has succeeded yet).

Surface staleness in the UI. A page that renders stale rows exactly like live ones looks correct, so nobody notices. The shape to avoid is an island that catches the error and leaves the last-known-good rows on screen under a heading like "Current pricing" — the page then serves confidently wrong numbers for as long as the source stays unreachable, and no authoring signal will tell you. Read the staleness headers and say so on the page.

fetch("/__data/sheet?sheetId=" + el.dataset.sheetId + "&tabName=Pricing")
  .then(async (r) => {
    if (!r.ok) throw new Error("no data available");
    return { rows: await r.json(), stale: r.headers.get("X-Data-Stale") === "1" };
  })
  .then(({ rows, stale }) => {
    renderHoweverYouWant(el, rows);
    // Say so — don't let old data pass as current.
    if (stale) el.querySelector(".caption").textContent = "Prices may be out of date";
  })
  .catch(() => showFallback(el)); // and label the fallback as unverified too

If your caption asserts freshness ("Current pricing", "Updated daily"), it must be conditional on X-Data-Stale: 0. Platform-side, a stale read also raises a pageable ops alert, so the team finds out even if the page stays quiet — but the visitor is only told if your island tells them.

Other backends — /__data/<connector>

sheet is one built-in source. Any other name after /__data/ resolves to a per-site connector configured on the platform — a REST backend (e.g. a Cloudflare-hosted inventory dashboard) the platform fetches server-side and passes back as raw JSON. The site just fetches by the connector's name; the backend URL (and any key) live in the connector config, never in page source:

GET /__data/inventory?sort=newest&page_size=12   → the backend's JSON
GET /__data/inventory/by-slug/<slug>             → a single record (if allowed)
fetch("/__data/inventory?sort=newest&page_size=12")
  .then((r) => r.json())
  .then((data) => renderCards(el, data)) // your markup + styling
  .catch(() => showFallback(el));

A backend may paginate. The snippet above names a page size and renders what comes back; if your backend returns a row count alongside the rows, compare them before treating one response as the whole set. Field names are your backend's, not the platform's — the proxy passes its JSON through untouched. Requesting a size that covers the collection is the simplest answer; if you deliberately show a subset, a real <a rel="next" href="?page=2"> that works with JS off keeps the rest reachable and crawlable. verify reports this as connectorPaging when a response declared more rows than it carried and the page has no link onward.

Connectors get the same last-known-good fallback and the same X-Data-Stale headers as sheet — check them here too.

Subpaths (e.g. /by-slug/…) and forwarded query params are whitelisted per connector, so a page can only reach the endpoints the connector allows. Adding a new backend is a config change, not new platform code — ask the platform team to wire the connector for your site.

Calling an external origin directly — the allowlist

The proxy above is the right tool for reading JSON with a hidden key. When a page must talk to an external origin _directly_ from the browser — most often an upload endpoint the operator owns on another host — an operator can allowlist that origin (POST /v1/allowlist). Once a human approves it, that origin is added to the site's connect-src and form-action, so your island can fetch()/POST to it and a <form> can submit to it. It is still script-safe: the origin can never load a script into the page. Prefer the same-origin /__data/ proxy whenever you are just reading data.

Built-in interactive patterns (no JS to write)

For the common cases you don't need to write any JS — author markup with a hook class and the platform's behavior runtime (auto-loaded first-party when a hook is present) wires it up. Style the states yourself.

HookMarkupBehavior
.mk-carousel.mk-carousel__slide children + [data-mk-prev]/[data-mk-next] buttons, and [data-mk-goto="n"] to jump straight to a slide; optional data-mk-autoplay="5000"shows one slide at a time — prev/next controls and Left/Right arrow keys both navigate. Aria comes free at bind (your own roles/labels are never overwritten; icon-only controls get names). Roles are only applied where they are valid: an <li> slide inside a list keeps its listitem role, and a <ul>/<ol> carousel root keeps its list role, because role="group" on either one makes the accessibility tree invalid — an li is a listitem only while its parent is still a list. Both still get aria-roledescription and the "N of M" label, so nothing an assistive reader announces is lost. This matters most for a reviews or listings carousel, since google-reviews and listings emit <ul>/<li>, and Lighthouse now audits the tree. The root publishes data-mk-index (1-based, matching the "N of M" a visitor reads) and data-mk-count, and the current slide carries .is-active alongside hidden — build counters or dots from those attributes, and crossfades from .is-active, instead of watching mutations. A thumbnail rail or a row of dots is [data-mk-goto="n"] on each control — 1-based, same numbering as data-mk-index, and it lives inside the carousel root like the other controls. An out-of-range or unparseable index does nothing rather than wrapping, so a typo cannot quietly show the wrong slide; a thumb whose <img> has real alt text keeps that alt as its accessible name, and only a control with no name at all is given one. Autoplay pauses while hovered or focused, stops for good once a control or arrow key is used, and never runs for visitors with prefers-reduced-motion (or under ?nomotion=1) — so never rely on rotation alone to expose a slide's content
.mk-trackany horizontally scrolling container of items + [data-mk-prev]/[data-mk-next]; put [data-mk-track-viewport] on the scroller when the arrows sit outside itthe many-in-view counterpart to the carousel (filmstrips, tile rows, logo strips) — you own the layout and the snapping in CSS; the runtime steps by one item width on the arrows, disables them at the ends, publishes data-mk-index (1-based, and it reaches data-mk-count at the far end however short the travel) / data-mk-count, publishes data-mk-overflow (scrollable px, 0 when it fits) so you can set your own "not worth arrows" bar, sets data-mk-fits on the root when everything already fits (hide the arrows on it), and re-measures when lazy images load. Swipe is native scrolling; nothing is ever hidden, so the content reads fine with JS off — see the example below
.mk-tabs[data-mk-tab="01"] triggers + [data-mk-panel="01"] panelsclick a trigger → shows its panel; active trigger gets .is-active
.mk-revealany elementships with .is-in-view; the runtime removes it below the fold and re-adds it on scroll — see the note below
.mk-gallery<img> childrenclicking an image opens a .mk-lightbox overlay you can browse — see below
.mk-nava [data-mk-menu] trigger next to a submenuclick toggles aria-expanded + .is-open on the trigger's parent (dropdown / mega-menu); closes on outside click
<div class="mk-tabs">
  <button data-mk-tab="01" class="is-active">Design</button>
  <button data-mk-tab="02">Build</button>
  <section data-mk-panel="01">…</section>
  <section data-mk-panel="02" hidden>…</section>
</div>

A track in full. Use .mk-carousel when exactly one slide should show at a time, and .mk-track when several items sit side by side and the visitor scrolls through them. The platform steps and reports; your CSS does everything visual, including the snapping — these two structural properties are yours to write, and they are the whole "setup":

<div class="mk-track">
  <button data-mk-prev>‹</button>
  <div class="tiles" data-mk-track-viewport>
    <article>…</article>
    <article>…</article>
    <article>…</article>
    <article>…</article>
  </div>
  <button data-mk-next>›</button>
</div>
.tiles {
  overflow-x: auto; /* the scroller — this is what makes it a track */
  scroll-snap-type: x mandatory; /* optional: snap points */
  display: flex;
  gap: 1rem;
}
.tiles > * {
  scroll-snap-align: start;
  flex: 0 0 240px;
}
.mk-track[data-mk-fits] button {
  visibility: hidden; /* everything already fits — the arrows have nothing to do */
}

Omit [data-mk-track-viewport] and the root itself is the scroller (the arrows then travel with the content, so most designs want the wrapper form above). A counter is markup reading data-mk-index / data-mk-count — same 1-based convention as the carousel, and the index reaches the count at the far end of the track no matter how little the row scrolls, so the counter is never stuck one short of its own maximum.

data-mk-fits means "nothing to scroll", not "not worth scrolling". It is set when the overflow is at most a pixel — a subpixel tolerance, not a judgment about whether controls earn their place. A row with 34px of travel is genuinely scrollable, so it does not get [data-mk-fits], and a full pair of arrows whose whole range of motion is smaller than one arrow looks broken. Where that bar sits is your design decision, so the platform publishes the number: data-mk-overflow is the scrollable pixels (0 when it fits), kept current as the visitor scrolls and re-measured when lazy images land.

CSS selectors cannot compare numbers, so a custom threshold is two lines in your island — the only thing you cannot get from [data-mk-fits] alone:

document.querySelectorAll(".mk-track").forEach((t) => {
  // Your bar, not ours: below 80px of travel, this design hides its arrows.
  t.classList.toggle("is-barely-scrollable", +t.dataset.mkOverflow < 80);
});

A thumbnail rail can drive a carousel and scroll itself at the same time. Put the rail inside the carousel root, give it class="mk-track", and its own [data-mk-prev]/[data-mk-next] scroll the rail while the [data-mk-goto] thumbs inside it still select slides:

<div class="mk-carousel">
  <div class="mk-carousel__slide">…</div>
  <div class="mk-track">
    <button data-mk-prev></button>
    <div data-mk-track-viewport>
      <button data-mk-goto="1"><img src="/media/…" alt="Lakeview cabin" /></button>
    </div>
    <button data-mk-next></button>
  </div>
</div>

A control belongs to the nearest root that answers to it. [data-mk-prev] / [data-mk-next] mean something to both, so the innermost of the two wins — which is why the rail's arrows above scroll only the rail. [data-mk-goto] means nothing to a track, so a track in between does not intercept it and the thumb still reaches its carousel. Nest two carousels and an arrow key presses the inner one only.

For anything beyond these, write a per-site island (above).

Scroll-reveal is default-visible, and you must keep it that way. Write both halves of the pair — hide the base class, reveal it on .is-in-view:

.mk-reveal {
  opacity: 0;
  transition: opacity 0.4s;
}
.mk-reveal.is-in-view {
  opacity: 1;
} /* ← never omit this */

The platform renders your .mk-reveal elements with .is-in-view already applied, and the runtime strips it off whatever is below the fold before watching for scroll. So the animation works exactly as you'd expect, and a visitor whose JavaScript never runs — blocked, failed request, an error earlier in your island — still sees the content.

That inversion exists because the reverse order was a trap: with opacity: 0 in the HTML and the class arriving only from JS, one site measured 12 of 14 sections completely blank with JS off. Nothing reported it, because nothing had failed.

What the platform cannot rescue is a missing second rule. If you hide .mk-reveal and never write an .is-in-view rule, there is no visible state to fall back to and the content is invisible for everyone, always. The gate warns about this (render.reveal_never_visible), and GET /v1/pages/:id/verify will show you the rendered result.

Hover-opening nav (mega-menus). .mk-nav opens on click, deliberately — click is the accessible default and it is the same interaction for touch, mouse and keyboard. Hospitality and resort nav usually opens on hover on desktop, and that is presentation, so it is yours to write. Pair :hover with the .is-open class the hook already toggles, and scope it to pointer devices so touch and keyboard keep the click behaviour:

@media (hover: hover) and (min-width: 900px) {
  .mk-nav > li:hover > .nav__sub {
    opacity: 1;
    visibility: visible;
  }
}
.mk-nav > li.is-open > .nav__sub {
  opacity: 1;
  visibility: visible;
}

Keep the .is-open rule outside the media query — that is the one keyboard users and touch devices rely on.

Markup you create after page load needs window.mk.bind(). The hooks are wired when the runtime runs, so a carousel, tab set or reveal that your island builds afterwards has no behaviour attached — and it fails silently, with nothing in the console. After injecting markup, hand the platform the new subtree:

container.innerHTML = html; // e.g. the `html` from /__data/listings
window.mk.bind(container); // ← wires any hooks inside it

It is safe to call repeatedly — an element already wired is skipped, so a rescan cannot give a carousel two click listeners. .mk-gallery is the exception: its lightbox is delegated from the document, so an injected gallery works with no bind() call.

The lightbox, in detail

Clicking any <img> inside a .mk-gallery opens the overlay on that photo and lets a visitor browse the rest of the same gallery — two galleries on one page never page into each other. You get, with no JS of your own:

ElementWhat it is
.mk-lightboxthe overlay (role="dialog", aria-modal, aria-label naming the current photo)
.mk-lightbox__imagethe full-size image
.mk-lightbox__closeclose (×)
.mk-lightbox__prev / .mk-lightbox__nextprevious / next — only when the gallery has more than one image
.mk-lightbox__count3 / 12, aria-live="polite" — also only for multi-image galleries
.mk-lightbox__captionthe shown photo's alt text, visible; hides itself when the alt is empty

Also handled: ←/→ to move, Esc or a background click to close, focus into the dialog on open and back to the photo on close, and html.mk-lightbox-open while it's open so the page behind doesn't scroll.

It works before you style it. The base layer ships the structural overlay — a fixed, dimmed, full-viewport dialog with the image centered and the controls placed — because unstyled it rendered as a plain block at the end of the page. Nothing aesthetic ships: no transitions, no brand colour, neutral black/white chips. Every rule is a single class, so your stylesheet re-skins any of it — backdrop, control shape, caption placement — without !important. The caption is the photo's alt text and is aria-hidden (screen readers already get the same text from the image), so write real alt text and the lightbox captions itself.

> On a CREA feed, leave object-fit: contain alone. The REALTOR® watermark is burned > into the top-left of every photo, and the lightbox is the surface that's meant to show > the whole uncropped frame — cover crops the watermark out while looking perfectly > fine. Same reason object-position: top left is the guidance for cards.

Want a thumbnail strip, or a main stage with prev/next on the page itself? Build it — that's presentation, and it's yours. If you build your own overlay, call classList.remove("mk-gallery") on the wrapper first so the platform handler stops matching and one click doesn't open two lightboxes.

Theme — design tokens

The theme is the site's colors, fonts, type scale, spacing, radius, and gradients. It has the same lifecycle as a page edit (validate → policy → publish or hold), and a change restyles every page, so it holds by default.

GET  /v1/theme                → current tokens + themeVersionId
PUT  /v1/theme  { …tokens }    → held (or published under an auto policy)
POST /v1/theme/publish { "themeVersionId": "thv_…" }

Tokens are validated for schema (e.g. font.scale in 1.067–1.5, hex colors, fonts from the vendored family list) and WCAG contrast on required text/background pairs before anything changes. Fonts are self-hosted by the platform (I13).

font.heading and font.body pick the main faces; the optional font.mono slot names a monospace family and exposes it as var(--font-mono) for labels, numbers, code, and "data terminal" styling.

Fonts

`` POST /v1/fonts (multipart: file=<woff2>, family, weight?, style?) GET /v1/fonts → list { id, family, weight, style, url } DELETE /v1/fonts/:id ``

The platform hosts it first-party (served at /fonts/<id> under font-src 'self') and injects an @font-face into every page, so font-family: "Your Font" in your stylesheet/page CSS resolves. Upload each weight/style you need (e.g. 400 + 700). Only woff2 is accepted, and it's your responsibility to hold a license for the font you upload.

Images, icons & embeds

Photos / bitmap images — <img> via imported media. Import first (off-site src is rejected), then reference the first-party URL. The one exception is our own Cloudflare Images account (see "What HTML you can write"); such an image gets no automatic width/height or srcset, so write them yourself.

POST /v1/media/import  { "url": "https://…/photo.jpg", "alt": "…" }
<img src="/media/ast_…" alt="Storefront at dusk" width="1200" height="800">

Every image is resized and re-encoded on serve, whether or not you ask. A /media/<id> request comes back capped at 1920px wide and in AVIF or WebP if the browser advertised either — so a 2400px JPEG upload is not a 2400px JPEG download. You do not have to do anything to get this, including in CSS.

?w=<px> overrides the width when you know better than the default:

/* a hero: the default 1920 cap is already right, nothing to add */
.hero {
  background-image: url(/media/ast_…);
}
/* a small avatar: ask for what you'll actually display */
.avatar {
  background-image: url(/media/ast_…?w=320);
}

Widths snap to 320 / 640 / 960 / 1280 / 1920 / 2560, and nothing is ever upscaled — a ?w= above the intrinsic width returns the intrinsic width. Use it for anything displayed much smaller than full-bleed; the default has no way to know your CSS box, so it errs large.

What you can upload:

ClassAcceptedSize limit
imagePNG, JPEG, WebP, AVIF, GIF, SVG25 MB
documentPDF, DOCX25 MB
videoMP4, WebM50 MB

Acceptance is decided by the file's actual contents, not its extension — a .png that is really something else is rejected, and renaming it changes nothing. SVG must additionally pass the SVG sanitizer, so a refused SVG means _that file_, not the format.

If an upload is rejected, uploads still work — that file did not qualify. Pick a different file or a different format and retry. Do not tell the client to upload it themselves: there is no client dashboard and no client-facing media library, so you are the only thing that can put an image on this site, and asking them hands them an instruction they cannot follow.

What the renderer injects into your <img> tags

The served HTML will not match what you authored, and this is deliberate — the platform, not you, is responsible for shipping responsible image markup. If you diff your source against the live page, expect these additions on every <img> whose src is a first-party /media/<id>, with or without a ?w= of your own:

AddedValueWhy
width / heightthe asset's real intrinsic sizereserves layout space, so the image can't shift the page (CLS)
srcset?w= rungs, capped at the intrinsic widthlets the browser pick a size per viewport — better than the serve-time default, which has to assume desktop
sizes100vwconservative default — override it if you know the CSS box
loadinglazy — on everything below the LCP imagekeeps the long tail off the critical path
decodingasync on those same imageskeeps a large decode off the main thread
fetchpriorityhigh, on the LCP image onlythe hero should not wait its turn

Worth knowing:

``css .team-card > img { width: 100%; } /* correct — height follows the ratio */ ``

This rule has zero specificity, so anything you write beats it. A fixed height, a height: 100%, or the absolutely-filled wrapper pattern all still win:

``css .photo { position: relative; aspect-ratio: 3 / 2; overflow: hidden; } .photo > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } ``

The one case where it changes what you'd otherwise get: an <img> whose own width/height attributes disagree with the file's real proportions is no longer stretched to fit them — it scales from the width. Injected dimensions always match the file, so this only reaches attributes you wrote by hand. Want the stretch? Say height in CSS.

_Before 2026-08-11, width: 100% alone left the injected height in force and drew the image at author-width × intrinsic-height — measured at a 5× vertical stretch. aspect-ratio did not rescue it, because a height attribute produces a definite used height and aspect-ratio only governs an axis that is auto. If you are reading older guidance that says to always pair width with height, that advice is now redundant rather than wrong. Pages published before the fix keep their old CSS until their next publish or re-render — baseCss is inlined into the stored document._

object-fit: cover hides a wrong image box from the naked eye — the layout is far taller than intended while the image itself still looks fine, because the crop discards the distortion. So if you are checking image geometry for any reason, compare declared aspect-ratio against rendered ratio rather than eyeballing it. GET /v1/pages/:id/verify does this for you on the live render.

A foreign-origin <img> (a hotlinked listing photo) can't be measured or resized, so it gets only the loading/decoding treatment.

Consequence for screenshots and measurement: because most images are lazy, a tool that captures the page without scrolling will photograph empty space where those images are. If you are measuring your own layout, width/height are present from the first paint, so geometry is reliable even before pixels land.

Capturing a page honestly — ?nolazy=1 and ?nomotion=1

Two query parameters, handled at the serve layer, so they work on every page of every site with nothing to author — including a site you did not build:

ParameterEffect
?nolazy=1every <img> and <iframe> loads immediately, whatever the markup says
?nomotion=1every scroll-reveal section is held revealed (the runtime is told not to un-reveal any) and all motion suppressed
https://client.example.com/our-facility?nolazy=1&nomotion=1

Do not ship a JS helper for this. The flags are inert when absent, a flagged response is no-store + noindex, and the cached page is untouched — so no visitor ever sees a difference. The response echoes X-Mk-Capture-Flags: nolazy,nomotion, so "the flag did nothing" is distinguishable from "the section really is empty".

?nomotion=1 also stamps data-mk-nomotion on <html>, which is how the platform's own hooks know to hold still — carousels do not autoplay and the track steps without smooth scrolling. If your island animates anything, read that attribute and skip the animation too, or your motion is the one thing left moving in a capture that was meant to be still.

They combine with the usual capture advice rather than replacing it. Measured on one 12-cell gallery with two map embeds (14 image boxes):

StrategyImage boxes painted
naive fullPagehangs
scroll, then fullPage9/14
?nolazy=1, then fullPage12/14
scroll + fullPage + captureBeyondViewport: false14/14
?nolazy=1 + scroll + captureBeyondViewport: false14/14

If you only need one page at one width, GET /v1/pages/:id/screenshot already does all of this for you and reports what it achieved in X-Mk-* headers. Reach for these flags when you are driving a real browser yourself — checking four viewports, diffing against a source site, or capturing a client-facing before/after.

Checking a fallback state — ?mkstate=

Components publish states you are told to handle and cannot otherwise reach: the roster's unavailable needs the feed to actually fail, empty needs a brokerage with nobody, and .mk-agent__initials needs a member who happens to have no headshot. CSS written for those states normally ships having never rendered once.

Same shape as the capture flags — serve layer, no page write, works on any page of any site:

https://client.example.com/directory?mkstate=agents:unavailable
https://client.example.com/directory?mkstate=agents:nophoto
ValueRenders
agents:unconfiguredthe roster's empty wrapper, as on a site with no office key
agents:unavailablethe empty wrapper for a FAILED read — deliberately no list at all
agents:emptythe ordinary wrapper with data-mk-count="0" and the empty paragraph
agents:nophotoevery card as if the member had no photo, so .mk-agent__initials renders
listings:errordata-mk-error on a .mk-listings-filters form, as on a feed outage

Comma-separate to combine: ?mkstate=agents:nophoto,listings:error.

Each value reproduces what the renderer really emits for that state, not a mock — that is the point, since CSS checked against a mock is only checked against the mock. Check unavailable and empty separately: they look similar and mean opposite things, and the whole reason unavailable renders no list is so your copy can say "we could not load this" instead of "this brokerage has no agents".

The response is no-store + noindex and the stored page is untouched, exactly like the capture flags — so no visitor ever sees a forced state. An applied value is echoed in X-Mk-State, and an unrecognised one is refused out loud in X-Mk-State-Ignored rather than quietly serving the normal page: a typo that looked like "my CSS is wrong" would be this same problem one layer up.

A full-page capture cannot be trusted to render position: fixed overlays at all — judge them at viewport size, or on pixels. An open .mk-lightbox, the consent banner and a sticky header all render misplaced or simply absent in a fullPage capture, while the computed styles, hit-tests and the real on-screen pixels say the overlay is correct. The same overlay was measured absent on a 2496px-tall page and present on a 2242px one, same code, same moment.

So captureBeyondViewport: false is necessary but not sufficient — combined with fullPage: true it still drops fixed overlays intermittently, and the trigger is document height, so it reproduces on some pages of a site and not others. The reliable answers are a genuine viewport capture (fullPage: false, or ?fullPage=false on the screenshot endpoint) or decoding pixels and comparing them. This is a different question from the table above: scroll + fullPage + captureBeyondViewport: false remains the right recipe for getting every image to paint, and is still the wrong tool for judging a modal.

_Added 2026-08-11. Previously this was per-site JavaScript, which meant a site built without the helper could not be captured honestly at all._

Self-hosted video — <video> via imported media. For clips you host yourself (short hero background loops, product demos), upload or import the file first, then point a plain <video> at the first-party URL. Off-site src on <video>/<source>/poster is rejected — the file must live on /media/…, exactly like images.

POST /v1/media          (multipart: file, alt)         — direct upload
POST /v1/media/import   { "url": "https://…/loop.mp4", "alt": "…" }

Accepted formats: mp4 and webm (sniffed by content, not extension), up to 50 MB per file. The /media/<id> route streams with HTTP range support, so seeking works. There is no custom player — the browser's native <video> is used, so controls gives you the default player chrome.

<!-- A normal, user-controllable video -->
<video src="/media/ast_…" poster="/media/ast_…" controls preload="metadata"
       width="1280" height="720" style="max-width:100%"></video>
<!-- Muted autoplay background loop for a hero. muted + playsinline are REQUIRED
     for autoplay to start on mobile; keep the file small and always set a poster
     so the first paint isn't blank. -->
<section style="position:relative;isolation:isolate">
  <video autoplay muted loop playsinline preload="metadata"
         poster="/media/ast_…"
         style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:-1">
    <source src="/media/ast_…" type="video/webm">
    <source src="/media/ast_…" type="video/mp4">
  </video>
  <div style="padding:6rem 2rem;color:#fff">
    <h1>Headline over the video</h1>
  </div>
</section>

Allowed <video> attributes: src, poster, width, height, controls, autoplay, loop, muted, playsinline, preload. Use self-hosting for short, brand-owned loops; for anything long-form prefer a YouTube/Vimeo <iframe> (next) — it sidesteps the 50 MB cap and gives you an adaptive player.

Icons / logos / simple vectors — inline <svg>. Write the SVG directly in your HTML; a safe subset is allowed (path, circle, rect, line, polygon, g, defs, gradients, <use>, text + presentation attributes). Use inline SVG for crisp, CSS-styleable icons (fill="currentColor" inherits your text color); use <img> for photos or complex artwork. <foreignObject>, <script>, and external refs inside SVG are rejected.

<svg viewBox="0 0 24 24" width="24" height="24" fill="none"
     stroke="currentColor" stroke-width="2" aria-hidden="true">
  <path d="M4 12l6 6L20 6"/>
</svg>

Maps / long-form video / third-party widgets — <iframe> embeds. Write a normal iframe with an https:// (or first-party) src. This is the right path for YouTube/Vimeo and any video too big for the 50 MB self-host cap. Wrap it in a responsive container yourself; the frame runs sandboxed to its own origin.

<div style="position:relative;aspect-ratio:16/9">
  <iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
          title="Intro" allow="fullscreen" allowfullscreen loading="lazy"
          style="position:absolute;inset:0;width:100%;height:100%;border:0"></iframe>
</div>

Google Maps — the map component

Use the component. It renders an interactive, fully branded Google map: styled map features, custom pin icons, multiple pins, and custom info windows.

<mk-component
  name="map"
  data-zoom="6"
  data-title="Our depots"
  data-pins='[
    {"lat":51.05,"lng":-114.07,"title":"Calgary","icon":"/media/ast_…",
     "html":"<h3>Calgary</h3><p>24h dispatch</p>"},
    {"lat":52.13,"lng":-106.67,"title":"Saskatoon"}
  ]'
  data-style='[{"featureType":"water","stylers":[{"color":"#2F7D86"}]}]'
></mk-component>
.mk-map {
  position: relative;
  aspect-ratio: 16 / 9;
}
.mk-map iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}
attributemeaning
data-pinsJSON array of pins — see the pin fields below.
data-styleA Google styles array, verbatim — the same JSON you'd pass to the JS API.
data-lat/data-lngMap centre. Optional — defaults to the first pin.
data-zoomOptional, default 12. With two or more pins the map fits them all instead.
data-titleAccessible name for the frame.

Pin fields:

fieldmeaning
lat / lngRequired. A pin without both is dropped.
titleAccessible label, and the info-window heading when there's no html.
htmlInfo-window content. Sanitized like page HTML.
iconFirst-party /media/<id> pin artwork.
iconWidth / iconHeightRendered pin size in px, 4–256. Send both or neither.
anchorX / anchorYWhere the art touches the coordinate, px from its top-left. Both or neither.

Things that will save you time:

``json { "lat": 51.05, "lng": -114.07, "icon": "/media/ast_…", "iconWidth": 34, "iconHeight": 48 } ``

anchorX / anchorY are rarely needed: the default anchor is the bottom-centre of the image, which is already right for a teardrop. Set them for art that points somewhere else — a centred dot wants the anchor in the middle.

_Added 2026-08-05._

Why it's an iframe, and why you can't load the Maps script yourself. The Maps JS API is what makes styles, custom markers and info windows possible, and a Moseik page pins script-src 'self'. So the platform serves the map as its own document on the API host and the component embeds it. That document is cross-origin to the site, so Google's code cannot reach the page's DOM or storage, and the URL is signed by the platform, so it cannot be forged or reused elsewhere.

_Not recommended:_ a CSS filter: grayscale(1) on a plain Maps Embed iframe also recolours a map — but it recolours Google's logo and attribution along with it, which looks cheap and sits badly with the Maps terms requiring attribution to remain unaltered. Use the component.

A map of your listings — the listings-map component

Put it next to a listings grid and it pins what the grid rendered:

<div class="split">
  <mk-component name="listings-map" data-title="Map of listings"></mk-component>
  <mk-component name="listings" data-city="Orangeville" data-limit="24"></mk-component>
</div>

You never pass it data-city or data-limit. Those go on the listings component; the map reads what the grid asked for and follows it.

It plots the listings in the area the visitor is looking at. The cards give it pins instantly, and from then on every pan or zoom re-queries the feed for that viewport with the grid's current filters applied — so panning to the next town shows that town. Markers are plain dots that cluster as you zoom out; click a cluster to zoom into it, click a dot for a card with the thumbnail, price, beds/baths and address.

Do not build a "search this area" button. Panning already re-queries, so the control would do nothing a drag does not.

Below zoom 10 it stops re-querying, keeps the pins it has, and sets data-mk-zoomed-out on the wrapper. A province-sized box matches tens of thousands of listings, and pinning an arbitrary few hundred of them would be a fiction. Say so rather than leaving a visitor wondering why the map went quiet:

.mk-listings-map[data-mk-zoomed-out]::after {
  content: "Zoom in to see listings in this area";
}

The map does not hold the grid's whole scope. It used to walk the entire filtered set into the map in the background — up to 42 upstream calls from a single page load, which was the largest source of the rate-limiting that came back to visitors as empty grids. One viewport query is capped at 2000 pins.

The popup is a link to the listing, so a visitor can browse the map and go straight to a property without touching the grid. It uses the card's own detail URL, so it can never point somewhere the grid wouldn't.

On a listing detail page the subject property is a teardrop pin, not a dot: always drawn on top, never clustered away, and a different _shape_ rather than a different colour so it survives whatever data-pin-color you pick and reads for someone with a colour vision deficiency. You need do nothing for it — the map already knows which listing the page is bound to.

Add data-live="false" to pin only what the page already has and never query the viewport. On a detail page that leaves just the subject, which is the right answer when the question is "where is this house" rather than "what else is around it".

Recolour the markers with data-pin-color and data-cluster-color (#rrggbb), and the map itself with data-style — the same Google Maps styles array map takes, so a site with both maps can brand them identically instead of shipping one greyscale and one stock blue.

<mk-component
  name="listings-map"
  data-style='[{"featureType":"water","stylers":[{"color":"#cfd8dc"}]}]'
></mk-component>

Wrap it in single quotes. A styles array is full of double quotes, and a data-style that is not valid JSON is discarded — the map then renders in stock colours with no error. The write gate rejects a malformed one rather than letting it through.

Use data-style rather than filter: grayscale(1): a CSS filter also desaturates Google's logo and attribution, which the Maps terms require to stay unaltered.

It already opens on your market — you do not have to place it. The map centres itself on the province (or town) your site is scoped to, so the example above needs no coordinates. It then frames itself around its pins as soon as it has any, which is what you normally see; the centre matters for the moment before that, and for a search that matched nothing.

Override it with data-lat and data-lng if you want it to open somewhere specific — both, or neither, since one alone is a different place rather than a vaguer one. data-zoom overrides the zoom on its own.

<!-- opens on your market, then frames its pins — the normal case -->
<mk-component name="listings-map"></mk-component>

<!-- opens on a specific spot instead -->
<mk-component name="listings-map" data-lat="43.92" data-lng="-80.09" data-zoom="12"></mk-component>

If your site serves one town rather than a whole province, ask staff to set the market's centre on the listings config instead of putting coordinates on every page — then every map on the site opens in the right place with no attributes at all.

On a listing detail page it shows that listing. Put a listings-map on an LDP and it pins the subject property, not the "more in the area" grid further down — the detail article carries the same coordinates the cards do, and the map prefers it. You do not need to pass it anything.

Give .mk-listings-map a height. It has none of its own, and the most common first mistake is a zero-pixel strip.

Interactions you get with no JS: clicking a pin adds .is-active to its card, hovering a card enlarges its pin. Style .is-active yourself.

The scroll-to-card only moves a scrollable container. If the card sits in a column with its own overflow-y: auto, clicking a pin scrolls that column. On a single-column page nothing scrolls — dragging the visitor down the page away from the map they just clicked is worse than not moving. So a two-pane layout is what this is designed for:

.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; height: 80vh; }
.mk-listings-map { height: 100%; position: sticky; top: 0; }
.split .mk-listings { overflow-y: auto; height: 100%; }

How many pins, and being honest about it

The map caps at 2000 pins. A city loads whole; a whole province does not (Ontario is ~111,000 listings, which is neither fetchable nor useful as dots). The wrapper publishes what happened, so the page can say so instead of quietly showing a fraction:

AttributeMeaning
data-mk-pins-shownpins actually on the map
data-mk-pins-totalpins it was handed
data-mk-pins-loadedhow many it has fetched beyond the visible cards
data-mk-pins-scopehow many the feed says match the current query
data-mk-loadingpresent only while it is fetching — see below
data-mk-zoomed-outpresent while too far out to query — see above

Some listings have no pin at all and that is normal — feed rows without coordinates, commonly vacant land, around 6% in Ontario and under 1% in Saskatchewan. They stay in the grid and are simply absent from the map.

.mk-listings-map[data-mk-pins-shown]::after {
  content: attr(data-mk-pins-shown) " of " attr(data-mk-pins-scope) " on map";
}

Say that the map is thinking — [data-mk-loading]

The map re-queries when the grid's filters change, and for a wide search that takes a few seconds, during which it still shows the previous answer. Left unstyled, that reads as broken rather than busy. The wrapper carries data-mk-loading for exactly as long as a fetch is in flight, so a spinner is pure CSS with no JS:

.mk-listings-map { position: relative; }
.mk-listings-map[data-mk-loading]::before {
  content: "";
  position: absolute; inset: 0; z-index: 2;
  background: rgba(255, 255, 255, 0.55) url(/media/ast_yourspinner) center no-repeat;
}

It appears only when there is something to fetch — a grid already covered by its own cards does not flash it — and it is cleared by the load that is still current, so clicking through filters quickly cannot switch it off while the newest search is still running.

Pins update in one step when the load completes, not progressively, so the attribute is the honest signal for "is more coming"; data-mk-pins-loaded only moves at the end.

The map queries what you are looking at. Pan or zoom and it re-asks the feed for that box, with the grid's current filters applied — so it answers the same question the page beside it does. Change a filter and it re-queries the area you are already on rather than waiting for you to move.

Two consequences worth designing around:

Do not fake a wider search client-side over a page of results — that looks like a search of the market while hiding almost all of it. If you need more than the viewport holds, narrow the filters instead.

Pagination for the grid

The grid publishes its own query and the feed's totals, which is what you need to build pager controls:

Attribute on .mk-listingsMeaning
data-mk-scopethe query it ran, as JSON, using the names /__data/listings accepts
data-mk-totalhow many listings match. Absent when unknowable — see data-mk-partial
data-mk-total-pageshow many pages at the grid's limit. Absent whenever data-mk-total is
data-mk-partialthe grid holds SOME of the matches and cannot count the rest. Both totals are absent, and a pager built from them must not render. Say "showing the first N" rather than inventing a number
data-mk-pagethe page being shown (only on a grid with data-url-filters)
data-mk-pagingthe data-paging mode in force (more / auto), when one is set
data-mk-exhaustedset once there is nothing left to load

Paginating. On a grid with data-url-filters, ?page=N on the page URL is a real server-rendered page — link the pager and you are done, and each page is a shareable URL. Without that attribute a published page is cached per route with the query string outside the cache key, so ?page=2 serves page 1: fetch /__data/listings with the scope plus &page=N, replace the grid's markup with the html it returns, and call window.mk.bind() on the new markup.

Endless listings — data-paging

Do not hand-roll infinite scroll. Add data-paging to the grid:

<mk-component name="listings" data-url-filters="all" data-paging="auto" data-limit="24">
</mk-component>
ValueBehaviour
moreloads the next page when the control is clicked
autothe same, and also when the control nears the viewport — endless scroll

The component emits the control for you as .mk-listings__more inside the grid wrapper, carrying data-mk-more (that attribute is what the runtime binds — style either hook, but do not remove it). Label it with data-more-label. New rows are appended; nothing already on screen is replaced. Style the control, and style the states published on .mk-listings: data-mk-paging carries the mode, data-mk-loading is set while a page is in flight, data-mk-error if one failed, and data-mk-exhausted once there is nothing left (the control is also hidden then).

.mk-listings[data-mk-loading] .mk-listings__more {
  opacity: 0.6;
}

Four things it handles that a hand-written scroll handler does not, and each one is why this is not left to you:

mk:listings:appended fires on the grid after each page (detail: page, total, totalPages, added), and mk:listings:error on a failed one.

Prefer more when the page has a footer worth reaching. Endless scroll pushes a footer permanently out of reach, which is a real accessibility cost — that is why both values exist rather than just the one.

Leave the map alone when you paginate. It follows the viewport, not the grid's page, so stepping through pages is nothing it needs to hear about. window.mk.listingsMap.refresh() is for a changed search: it reloads the new query when the filters moved, and merges the new cards in when only the page did, so it can never lose pins. A <form class="mk-listings-filters"> already calls it for you.

refresh() is supported, not deprecated — it is the hook for controls that are not a form, and the form itself goes through it. It tells the two cases apart by reading the grid's data-mk-scope, so call it _after_ you have replaced the grid, never before: on the old markup it sees the old query and correctly decides nothing changed.

Page against data-mk-total-pages, never against "was this page full". A page can legitimately come back short, so "fewer than the limit means last page" is wrong. Note also that the feed stops serving results past roughly page 1750 even when it claims more, so clamp your pager to what actually returns rows.

Structured data (JSON-LD)

Read this before auditing a site's schema — a good deal of it already ships.

Every page the platform serves already carries, generated from validated site data:

@typeFrom
WebSitethe site name and primary hostname
Organization, or LocalBusiness when contact data existssiteConfig.contact — name, telephone, email, full PostalAddress, openingHours
BreadcrumbListthe route, on nested pages

So a failing LocalBusiness audit is a site-config problem, not a markup problem. Fix it with PUT /v1/site contact data and it corrects on every page at once. Adding a LocalBusiness block by hand would give the page two competing definitions of the same business, which is worse for rich results than having one.

A listing detail route additionally emits RealEstateListing, automatically. Built from the feed data already loaded for the page — address, coordinates, beds/baths, floor size and price, plus the MLS number, the REALTOR.ca link and the listing brokerage on an MLS row. A builder's lot has none of those three, so they are omitted. You do not author it and you should not add your own.

It mirrors only what the page displays, which is the line that keeps it defensible under CREA's display rules, and it follows the same omissions as the card: no price on a listing the feed prices at 0, no bedroom count on land that reports none, and no building type asserted beyond what PropertySubType actually said.

Adding the types the platform cannot infer

FAQPage, Service, Article, Product and friends are page-specific, so you supply them:

PATCH /v1/pages/:id
{ "meta": { "schema": { "@type": "FAQPage", "mainEntity": [ … ] } } }

There is no way to write a <script type="application/ld+json"> block yourself, and that is deliberate. The sanitizer rejects every script element without exception — that rule is what makes the rest of the security posture verifiable by reading it — so structured data arrives as data and the platform serializes it. You lose nothing: the output is the same ld+json block, in the head, where crawlers expect it.

Files the platform serves for you

Four machine-readable files exist on every site, generated from your pages. None of them is authorable — a page's payload is a body fragment served as text/html, so a route named /llms.txt would answer with an HTML document rather than a text file. Do not create pages for these, and do not hand-write one.

URLWhat it isBuilt from
/robots.txtcrawl rules + the sitemap pointerfixed, plus your primary hostname
/sitemap.xmlevery indexable URLpublished pages, listing detail URLs, visible collection rows — sharded past 50k
/feed.xmlRSSpages with meta.publishedAt; 404s until at least one exists
/llms.txtthe site described for an AI agentthe site name, the home page's meta.description, and your published page and post titles + descriptions

/llms.txt

What an agent reads to find its way around the site, and what PageSpeed Insights audits under Agentic Browsing. Generated per request, so it can never be stale — but what lands in it is decided entirely by page data you control:

On a *.moseik.app preview host belonging to a site that has its own domain, and on a site created as a copy, /llms.txt answers 404 — the same reasoning that makes robots.txt disallow those hosts. A titled, described tour of every page is the last thing a hostname that must not be advertised should hand out.

Local area data — neighborhood & community pages

Real per-boundary data for the place a page is about: who lives there, what housing costs, what is on the market. Read it before writing the page; without it every community page on a site ends up saying the same four adjectives about a different place.

Three endpoints, all GET and all content:read. Over MCP they are find_areas, get_area_profile and rank_areas.

EndpointAnswers
/v1/areas?name=&lat=&lng=which boundaries match this place
/v1/areas/:idpopulation, income, tenure, lifestyles, active market for one area
/v1/areas/rank?lat=&lng=the areas around a point, ordered by how attractive they are to farm

Resolve the place first. Boundary ids are opaque and are not guessable from a name. /v1/areas returns candidates in alphabetical order, not relevance order, so the first row is not the one you meant — pass lat/lng, since a bare name matches across countries.

Read coverage before you read the numbers

The vendor is US-first and answers 200 with zeros for data it does not have, so an absence is indistinguishable from a finding unless you check. Each profile carries a coverage block and a notes array saying which it is.

For a Canadian market: boundaries, demographics (Statistics Canada 2021) and active listings are present; sold data and schools are not. A sold count of 0 in Canada means the vendor does not cover it. Writing "no homes sold here" from that publishes a falsehood.

Coverage also varies by city inside one province — Regina has neighborhood and micro-neighborhood boundaries, Saskatoon has none. An empty layer is a reason to retry without it, not a reason to conclude the town is unknown.

Two more fields carry obligations rather than information:

partOf gives the containing city, province and postal code with their boundary ids — how you widen a neighborhood too small to have a market worth printing.

Ranking is an ordering, not a recommendation

/v1/areas/rank scores each area 1-100 relative to the others in that same result. 100 means "the best of these", never "a good neighborhood", and two calls around different points cannot be compared. Every response carries the raw metrics and a plain-English method precisely so a human can disagree with it — show them those, not just the rank.

Areas with no active listings come back in skipped with a reason rather than scoring zero, and a scored area never scores 0, so the two can never be confused on a shortlist.

The safety gate

Every write runs: sanitize (hard — the envelope above) → placeholder resolution (hard — components/partials must exist) → budgets (hard) → compliance (fair-housing scan on real-estate copy: hard terms block, soft terms hold for review). The served page additionally carries a strict CSP. Then the change is classified:

"Held" doesn't mean every change needs approval — it means the pipeline flagged _this_ change. A held version waits in the staff review queue; the validation report on the version names exactly what to fix if something was rejected.

See your work — screenshots

GET /v1/pages/:id/screenshot            → PNG of the published page
GET /v1/pages/:id/screenshot?version=ver_…&w=1200   → PNG of a specific draft

The platform renders the page in a real browser and returns an image, so you can run the pixels-first loop (edit → screenshot → compare) with no local browser. See Cloning a site.

Need an accessibility read? GET /v1/pages/:id/verify runs an on-demand audit (computed contrast + core a11y checks) — call it when you want it; it's never forced on publish.

A worked example

POST /v1/lease
PUT  /v1/stylesheet { "css": ".hero{padding:4rem 2rem;background:var(--color-primary);color:var(--color-primary-contrast);text-align:center}.mk-form__submit{background:var(--color-primary);color:#fff;border:0;padding:.6rem 1.2rem;border-radius:var(--radius-button)}" }
POST /v1/pages {
  "route": "/",
  "title": "Acme Realty",
  "payload": {
    "html": "<header class=\"hero\"><h1>Acme Realty</h1><p>Homes in the west end.</p></header><main><section><h2>Get in touch</h2><mk-component name=\"contact-form\" data-phone=\"true\"></mk-component></section></main>"
  }
}
POST /v1/pages/:id/publish
GET  /v1/pages/:id/screenshot   → eyeball it

Work in small, validated steps. If a write is rejected, the reason code (and the version's validation report) names exactly what to fix.