docs
/
AppEngine API

Pages and rendering

The four shapes a page record can take, how the renderer chooses between them, where page CSS and JavaScript live, and how to write a page without losing what was there.

A page is a record of datatype page. What appears on screen depends on which of four fields is populated — and the renderer's order of preference is not the order you would guess.

The four shapes

FieldWhat it holdsProduced by
data.screens[]An array of { html } — a start/end wrapper pairThe visual editor
data.htmlA complete HTML documentA deploy script, or an editor save
data.contentVirtual-DOM JSONThe legacy editor
data.sections[]Section blocksAn older editor format

The renderer takes the first it finds, and screens is checked before html.

This is the most expensive footgun on the platform.

A page saved even once in the visual editor carries data.screens. From then on, writes to data.html deploy successfully — 201, no error, the record updates — and change nothing on screen, because screens is still winning.

There is no warning anywhere in the response. The usual outcome is three or four redeploys, then an hour spent looking for a cache that does not exist.

Taking ownership of a page

When a deploy script owns a page, it should own the shape too. Clear the others in the same write:

{
  "data.html": "<!DOCTYPE html>…",
  "data.screens": [],
  "data.sections": [],
  "data.content": ""
}

Do this on the first deploy of any page that already existed. It is idempotent and costs nothing on pages that never had the other shapes.

Checking what a page actually has

s, rec = req("GET", f"/repository/get/page/{sk}", token=t)
d = rec["data"]
print("html:",     len(d.get("html") or ""))
print("screens:",  len(d.get("screens") or []))
print("content:",  bool(d.get("content")))
print("sections:", len(d.get("sections") or []))

Use get, not find — find returns a partial projection and may not include these fields at all, which makes an editor-saved page look like a clean one.

How data.html is rendered

It is not handed to the browser as a document. It is parsed into a virtual DOM and re-rendered by the host application.

const root = parse(html, {
  lowerCaseTagName: false,
  comment: false,
  voidTag: { tags: ['area', 'base', 'br', /* … */] },
  blockTextElements: { script: false, noscript: false, style: false, pre: true },
});

Three consequences follow from that configuration, and they explain most surprises:

Script bodies are discarded. script: false in blockTextElements means the element survives and its contents do not. A <script> in your page HTML is an empty node.

The <head> is injected as raw HTML via dangerouslySetInnerHTML. Styles and stylesheet links work — browsers process those wherever they appear. Scripts do not, because HTML inserted that way never executes them.

Icon links are removed deliberately. <link rel="icon">, apple-touch-icon and mask-icon are stripped so a template cannot shadow the real favicon, which is owned by the site record.

Attributes survive intact, which is why inline event handlers (onclick, onload) are the one kind of scripting that works from page HTML.

Where page CSS and JavaScript go

Not in the HTML. A page record carries a top-level style object — a sibling of data, with four slots:

SlotInjected as
style.javascriptAn inline <script> in the head
style.cssAn inline <style>
style.scriptLinks<script src> elements
style.styleLinks<link rel="stylesheet"> elements
req("POST", f"/repository/update-partial/page/{sk}", {
    "sk": sk,
    "version": rec["version"],
    "style": {"javascript": open("app.js").read()},   # beside "data"
}, token=t)

style is top-level. data.style is read by nothing — a payload written there is stored, returns 201, and never runs.

print("style" in rec)                   # True  — correct
print("style" in rec.get("data", {}))   # False — the wrong place

There is no practical size limit: a production customizer runs on 113,115 characters in this field.

Because the script is injected into the head, it executes before the body is parsed and before the browser runtime mounts. Anything you write there needs a readiness gate — see Where your code goes.

{"style": {}} clears the slots.

Writing a page

s, rec = req("GET", f"/repository/get/page/{sk}", token=t)

req("POST", f"/repository/update-partial/page/{sk}", {
    "sk": sk,
    "version": rec["version"],          # from the get you just did
    "data.html": html,
    "data.title": title,
    "data.description": desc,
}, token=t)

Two rules:

Dot-paths, never a nested object. {"data": {...}} replaces the whole data field — every key you did not include is gone.

Always send the version you just read. A stale version is rejected rather than silently overwriting whoever wrote in between.

Creating a page

s, d = req("PUT", "/repository/create", {
    "datatype": "page",
    "name": name,
    "isNew": True,
    "data": {"name": name, "slug": name, "html": html,
             "title": title, "description": desc},
}, token=t)

isNew: True is required. Without it:

{ "statusCode": 400,
  "error": "create => Not a new metrics, please use update or set the new property" }

The flag is isNew — the message's phrasing has sent people looking for a field called new.

Wrapper pages

A page can provide chrome — header, footer, navigation — that wraps other pages, including built-in routes such as checkout, account and search. Without one, those routes render with platform defaults rather than your site's design.

If your site has any built-in route in its navigation, you want a wrapper page.

Verifying a deploy

A 201 means the record changed. It says nothing about what renders.

An unknown path returns 200 with the site's fallback body. Status codes are not a validity check — curl -w '%{http_code}' returns 200 for any URL you invent.

Check the <h1> instead:

m = re.search(r"<h1[^>]*>(.*?)</h1>", fetch(route), re.S)
h1 = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip() if m else "(no h1)"

A route showing the generic site name where you expected a page title is a 404 wearing a 200.

The served HTML contains your markup more than once — the rendered DOM plus the framework's serialised payload, where < appears as <. A naive grep over the raw response counts each element two or three times.