You deploy a page with a <script> block. The page renders. The script does nothing. There is no console error, the tag is right there in the DOM, and everything looks correct.
There is a supported place for page JavaScript. It is not the HTML.
The short version
| Where you put it | Runs? |
|---|---|
Top-level style.javascript on the page record | Yes — this is the one |
<script> inside data.html | No — the parser discards script bodies |
<script> in the document <head> | No — head content is injected via innerHTML, which never executes scripts |
data.style.javascript | No — nothing reads that path |
Inline event-handler attributes (onclick, onload) | Yes — attributes survive intact |
Note the difference between style and data.style. style is a top-level field on the page record, a sibling of data — not a key inside it. Writing to data.style.javascript is silently ignored, and that mistake is what sends people looking for workarounds they do not need.
The supported path: style.javascript
The renderer collects each page's style slots — css, javascript, styleLinks, scriptLinks — and injects them into the document head as real elements. A script that arrives this way is a genuine <script> with a body, and it executes normally.
There is no meaningful size limit in practice. Print Oxygen's /design customizer — an on-canvas text editor with font pickers, rotation, curving, flip and duplicate — is 113 KB of JavaScript in exactly this field, with data.html holding only the markup skeleton it renders into.
Writing it
JS = """
(function () {
var form = document.getElementById("ct-form");
if (!form) return;
form.addEventListener("submit", function (e) {
e.preventDefault();
// …
});
})();
"""
s, rec = req("GET", f"/repository/get/page/{SK}", token=t)
req("POST", f"/repository/update-partial/page/{SK}", {
"sk": SK,
"version": rec["version"],
"style": {"javascript": JS}, # top level, beside "data"
}, token=t)Read it back and confirm where it landed:
s, rec = req("GET", f"/repository/get/page/{SK}", token=t)
print("style" in rec) # True — correct
print("style" in rec.get("data", {})) # False — the wrong placeThen check it executed, on the rendered page:
// in the page's own console
window.__YOUR_FLAG // set one at the top of your payload while developingKeep the JavaScript in a real .js file in your source tree and have the deploy script read it into the style.javascript field. Editing a 113 KB payload as a quoted string inside a record is not a thing you want to do twice. Pair it with a style.css slot the same way.
Clearing it
"style": {} removes the slot. An empty object is treated as no script, which is the clean way to take a page back to static.
Why the <script> tag in your HTML does nothing
Page HTML is not handed to the browser as a document. It is parsed into a virtual DOM and re-rendered by the host application, and the parser is configured like this:
const root = parse(html, {
lowerCaseTagName: false,
comment: false,
voidTag: { tags: ['area', 'base', 'br', /* … */] },
blockTextElements: { script: false, noscript: false, style: false, pre: true },
});blockTextElements lists the elements whose raw text content is retained. script: false means the script's body is discarded during parsing. The element survives; its contents do not.
You can see it from the console on any rendered page:
[...document.querySelectorAll('script:not([src])')]
.filter(s => s.textContent.length === 0)Your script tags are in that list. They are empty elements.
The <head> fares no better, for a different reason: head content from page HTML is injected with dangerouslySetInnerHTML, and HTML inserted that way never executes its scripts — a browser rule, not a platform choice. Styles and stylesheet links work fine, because browsers process those wherever they appear.
An external <script src="…"> is a real element with no body to strip, so it does survive. But you are then hosting and versioning a separate file that loads after the page, for no benefit over the style.javascript slot.
The attribute bootstrap: when you only control the HTML
Attributes survive parsing intact, so an inline event handler runs:
<button onclick="this.parentNode.classList.toggle('open')">Menu</button>That gives you a second route, which matters in one specific situation: your pipeline produces HTML and nothing else. A static-site deploy that writes data.html and never touches the record's other fields can carry its JavaScript inside the markup, by putting the whole payload on a 1×1 image's onload:
<img alt="" aria-hidden="true" width="1" height="1"
style="position:absolute;left:-9999px;top:0"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
onload='(function(){ /* your page script */ })()'>The image decodes immediately from its data URI, the handler fires, and the code runs synchronously with the surrounding DOM already present. Place it just before </body>.
Prefer style.javascript. The bootstrap has real constraints — no single quotes anywhere in the payload, <, > and & must be HTML-escaped, and everything has to be one expression on one attribute. If your deploy script can write the record's style field, it should.
Rules, if you use it
No single quotes anywhere in the payload. Not in strings, not in comments, not in an apostrophe in a user-facing message:
show("We’ll email you"); // ’
show("Booked — check your inbox"); // —HTML-escape <, > and &:
if (a < b && c > d) // source
if (a < b && c > d) // in the attributeGenerate it; never hand-write the escaping:
import html as H, re
def bootstrap(js_source):
assert "'" not in js_source, "payload must not contain a single quote"
payload = H.escape(re.sub(r'\s*\n\s*', ' ', js_source).strip(), quote=False)
pixel = ("data:image/gif;base64,"
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
return ('<img alt="" aria-hidden="true" width="1" height="1" '
'style="position:absolute;left:-9999px;top:0" '
f'src="{pixel}" onload=\'{payload}\'>')
html = html.replace("</body>", bootstrap(open("page.js").read()) + "\n</body>", 1)The assertion is not decoration. A single quote truncates the attribute at that character and the rest of your code becomes stray text in the markup — which renders as nothing and reports nothing.
One bootstrap per page. A deploy script that appends by slicing the document will re-capture a bootstrap from a previous run, and you end up with two, then three, each binding the same listeners. The symptom is duplicate records: one click, two identical submissions milliseconds apart.
IMG_RE = re.compile(r'<img alt="" aria-hidden="true"[^>]*?onload=\'[^\']*\'\s*>', re.S)
html = IMG_RE.sub('', html) # strip every existing bootstrap
assert '<img alt="" aria-hidden="true"' not in html
html = html.replace('</body>', bootstrap(js) + '\n</body>', 1)Strip and regenerate — do not try to keep the right one. A dedupe pass that picks a survivor will eventually pick a corrupted one. Two payloads concatenated into a single attribute are a syntax error, the handler is never created, and the page falls silent with the attribute sitting there looking perfectly fine.
Do not split handlers from the payload
This applies to both routes, and it costs an afternoon.
<!-- the payload defines it… -->
<img … onload='window.BMswitch = function (el) { /* … */ };'>
<!-- …and this calls it -->
<button onclick="BMswitch(this)">Tab</button>Locally this works. On the deployed page BMswitch is undefined when the click fires, even though the payload demonstrably ran. Attribute handlers on a hydrating host are not guaranteed to see globals assigned by another attribute handler.
Bind listeners with addEventListener from inside your script rather than defining globals for markup to call. If you must use an inline onclick, put the whole handler body in the attribute.
document.querySelectorAll("[data-tab]").forEach(function (el) {
el.addEventListener("click", function () { switchTo(el); });
});Make it idempotent either way
A page can render more than once on a hydrating host. Mark what you bind to:
(function () {
var form = document.getElementById("ct-form");
if (!form || form.dataset.bound) return;
form.dataset.bound = "1";
form.addEventListener("submit", function (e) {
e.preventDefault();
if (form.dataset.sending) return; // second guard: one submit, one record
form.dataset.sending = "1";
// … and delete form.dataset.sending in the final .then()
});
})();Verifying
Check state, not the presence of a tag:
JSON.stringify({
styleJs: !!window.__YOUR_FLAG,
boots: document.querySelectorAll('img[aria-hidden="true"][width="1"]').length,
attrLen: document.querySelector('img[aria-hidden="true"]')?.getAttribute('onload')?.length,
bound: document.getElementById('ct-form')?.dataset.bound,
appmint: !!window.appmint,
})| Reading | Means |
|---|---|
styleJs: false, and you wrote style.javascript | Check it is top-level style, not data.style |
boots: 2 or more | Your deploy script is appending, not replacing |
attrLen much larger than your source | Two payloads concatenated — syntax error, nothing ran |
bound: undefined, one boot, plausible length | The payload threw; look for an unescaped < or & |
bound: "1" | It ran |
When you need more than a page script
If you need modules, a build step or a framework, you want a real frontend consuming AppEngine as an API — see Client Integration.
If you need platform functionality rather than custom logic, check whether window.appmint already covers it — cart, products, reservations, forms, drawers and toasts are all there, and data-action attributes reach them with no script at all:
<button data-action="cart.add" data-payload='{"sku":"ST-SOFA","quantity":1}'>
Add to basket
</button>