Skip to content

Custom Javascript Guide

This guide explains how to use the Custom JavaScript feature in NotionApps to add client-side behavior to a published app.

Custom JavaScript lets app builders run JavaScript in the end-user app after the app has loaded in the browser. You can use it for lightweight enhancements such as adding helper UI, sending analytics events, reading URL parameters, attaching event listeners, or applying small DOM-based customizations.

This guide is intended for:

  • App builders who want to add lightweight client-side behavior.
  • Technical writers documenting NotionApps customization workflows.
  • Customer success teams helping users validate custom scripts.
  • Developers or advanced users who understand browser JavaScript.

Custom JavaScript can be used to:

  • Log a confirmation message when the end-user app loads.
  • Add small UI elements, such as a footer note or help banner.
  • Read query parameters from the page URL.
  • Add event listeners to buttons, links, or forms.
  • Send client-side analytics events.
  • Modify the DOM after the app renders.
  • Observe dynamic page changes with MutationObserver.
  • Add small accessibility or usability enhancements.

Custom JavaScript should be used for small enhancements. It should not replace core app configuration, permissions, backend validation, or NotionApps product features.

Do not use Custom JavaScript to:

  • Store secrets, API keys, tokens, or private credentials.
  • Hide sensitive data that should not be shown to users.
  • Bypass permissions, authentication, or access controls.
  • Depend on private internal class names that may change.
  • Load untrusted third-party scripts.
  • Collect personal data without user consent.
  • Perform heavy computations in the browser.
  • Make the app unusable if the script fails.

Before adding Custom JavaScript, make sure:

  • You know the exact behavior you want to add.
  • The app works correctly without the custom script.
  • The script does not require private backend credentials.
  • You have tested the script in a safe environment.
  • You understand how to open browser DevTools and check the Console.

To add Custom JavaScript:

  1. Open the app in the NotionApps builder.
  2. Go to the app settings area.
  3. Find the Custom JavaScript section.
  4. Enable Custom JavaScript.
  1. Paste your JavaScript into the editor.
  2. Save the app.
  3. Publish or re-publish the app.
  4. Open the published end-user app.
  5. Verify the script in browser DevTools.

Custom JavaScript runs in the browser for the end-user app.

This means:

  • It can access browser APIs such as document, window, and localStorage.
  • It can read the current URL.
  • It can modify visible page elements.
  • It can attach event listeners.
  • It can send network requests if allowed by browser security rules and CORS.
  • It cannot directly access server-only code.
  • It cannot safely store secrets because users can inspect browser code.

The supported run location is:

end_user_only

This means the script is intended to run only in the rendered end-user app.

Use a simple script first to confirm that Custom JavaScript is working.

console.log("NotionApps custom JavaScript loaded");
document.documentElement.setAttribute("data-custom-js-loaded", "true");

After publishing, open the end-user app and check the browser Console. You should see:

NotionApps custom JavaScript loaded

You can also run this in the Console:

document.documentElement.getAttribute("data-custom-js-loaded");

Expected result:

true

Wrap custom scripts in an immediately invoked function expression, also called an IIFE. This keeps variables out of the global scope and reduces the chance of naming conflicts.

(function () {
console.log("Custom JavaScript started");
})();

For most scripts, use this pattern:

(function () {
try {
console.log("Custom JavaScript started");
// Add your code here.
} catch (error) {
console.error("Custom JavaScript error", error);
}
})();

The try...catch block helps prevent one script error from breaking the rest of the page.

The end-user app is wrapped in a root element with this attribute:

data-notionapps-end-user-root="true"

Use this selector to find the app root:

const root = document.querySelector('[data-notionapps-end-user-root="true"]');

Always check whether the element exists before using it:

const root = document.querySelector('[data-notionapps-end-user-root="true"]');
if (!root) {
console.warn("NotionApps end-user root not found");
return;
}

The app may render dynamically. If your script runs before the app root is available, wait for it.

Use this helper:

function waitForElement(selector, callback, timeoutMs) {
const existingElement = document.querySelector(selector);
if (existingElement) {
callback(existingElement);
return;
}
const observer = new MutationObserver(function () {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
callback(element);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
window.setTimeout(function () {
observer.disconnect();
}, timeoutMs || 10000);
}
waitForElement('[data-notionapps-end-user-root="true"]', function (root) {
console.log("End-user app root is ready", root);
});

This pattern is useful because many modern apps render or update content after the initial page load.

The browser load event may have already fired by the time your custom script runs.

This can fail:

window.addEventListener("load", function () {
console.log("The page loaded");
});

Prefer code that runs immediately and checks the current document state:

(function () {
function run() {
console.log("Running custom script");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();

For app elements that render later, use MutationObserver or a waitForElement helper.

This example adds a small footer note to the end-user app.

(function () {
function addFooter(root) {
if (root.querySelector("[data-brand-footer]")) {
return;
}
const footer = document.createElement("div");
footer.setAttribute("data-brand-footer", "true");
footer.textContent = "Powered by Acme Client Portal";
footer.style.cssText =
"margin: 24px auto; text-align: center; font-size: 12px; color: #64748b;";
root.appendChild(footer);
}
function waitForElement(selector, callback) {
const existingElement = document.querySelector(selector);
if (existingElement) {
callback(existingElement);
return;
}
const observer = new MutationObserver(function () {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
callback(element);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
waitForElement('[data-notionapps-end-user-root="true"]', addFooter);
})();

This script includes a guard:

if (root.querySelector("[data-brand-footer]")) {
return;
}

The guard prevents duplicate footers if the script runs more than once.

This example adds a dismissible help banner above the app content.

(function () {
const storageKey = "acme-help-banner-dismissed";
function addBanner(root) {
if (window.localStorage.getItem(storageKey) === "true") {
return;
}
if (root.querySelector("[data-help-banner]")) {
return;
}
const banner = document.createElement("div");
banner.setAttribute("data-help-banner", "true");
banner.style.cssText =
"display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; margin-bottom: 16px; border: 1px solid #d8ebe7; border-radius: 6px; background: #ecfdf5; color: #064e3b; font-size: 14px;";
const message = document.createElement("span");
message.textContent = "Need help? Contact support@example.com.";
const button = document.createElement("button");
button.type = "button";
button.textContent = "Dismiss";
button.style.cssText =
"border: 0; background: transparent; color: #065f46; font-weight: 600; cursor: pointer;";
button.addEventListener("click", function () {
window.localStorage.setItem(storageKey, "true");
banner.remove();
});
banner.appendChild(message);
banner.appendChild(button);
root.prepend(banner);
}
const root = document.querySelector('[data-notionapps-end-user-root="true"]');
if (root) {
addBanner(root);
}
})();

This example reads a query parameter from the URL and logs it.

URL:

https://example.notionapps.com/?source=client-email

JavaScript:

(function () {
const params = new URLSearchParams(window.location.search);
const source = params.get("source");
if (source) {
console.log("Traffic source:", source);
}
})();

You can use this pattern for analytics labels, user education flows, or non-sensitive display changes.

Example: Add a Class Based on a URL Parameter

Section titled “Example: Add a Class Based on a URL Parameter”

This example adds a class to the app root when the URL contains theme=client.

(function () {
const params = new URLSearchParams(window.location.search);
const theme = params.get("theme");
const root = document.querySelector('[data-notionapps-end-user-root="true"]');
if (root && theme === "client") {
root.classList.add("client-theme");
}
})();

You can pair this with Custom CSS:

[data-notionapps-end-user-root="true"].client-theme {
--brand-primary: #0f766e;
}

This example sends a simple event to an analytics endpoint.

(function () {
const payload = {
event: "app_viewed",
path: window.location.pathname,
timestamp: new Date().toISOString()
};
fetch("https://analytics.example.com/events", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload),
keepalive: true
}).catch(function (error) {
console.warn("Analytics event failed", error);
});
})();

Only use endpoints that are approved by your organization.

This example listens for clicks on buttons inside the end-user app.

(function () {
const root = document.querySelector('[data-notionapps-end-user-root="true"]');
if (!root) {
return;
}
root.addEventListener("click", function (event) {
const button = event.target.closest('button, [role="button"]');
if (!button || !root.contains(button)) {
return;
}
console.log("Button clicked:", button.textContent.trim());
});
})();

This uses event delegation. Event delegation is useful because buttons may be added or re-rendered after the script runs.

Some app content changes after navigation, filtering, searching, or form interactions. Use MutationObserver to respond to those changes.

(function () {
const root = document.querySelector('[data-notionapps-end-user-root="true"]');
if (!root) {
return;
}
const observer = new MutationObserver(function () {
console.log("The end-user app content changed");
});
observer.observe(root, {
childList: true,
subtree: true
});
})();

Use observers carefully. Avoid expensive work inside the observer callback.

This example adds a floating button that scrolls the user back to the top of the page.

(function () {
if (document.querySelector("[data-back-to-top]")) {
return;
}
const button = document.createElement("button");
button.type = "button";
button.setAttribute("data-back-to-top", "true");
button.textContent = "Top";
button.style.cssText =
"position: fixed; right: 16px; bottom: 16px; z-index: 9999; border: 0; border-radius: 6px; padding: 10px 14px; background: #0f766e; color: white; font-weight: 600; cursor: pointer;";
button.addEventListener("click", function () {
window.scrollTo({
top: 0,
behavior: "smooth"
});
});
document.body.appendChild(button);
})();

Sometimes a customer may need to load an approved external script, such as an analytics script.

(function () {
if (document.querySelector('script[data-custom-analytics="true"]')) {
return;
}
const script = document.createElement("script");
script.src = "https://analytics.example.com/client.js";
script.async = true;
script.setAttribute("data-custom-analytics", "true");
script.onload = function () {
console.log("Analytics script loaded");
};
script.onerror = function () {
console.warn("Analytics script failed to load");
};
document.head.appendChild(script);
})();

Always handle errors so a script does not break the page.

Recommended pattern:

(function () {
try {
// Custom code goes here.
} catch (error) {
console.error("Custom JavaScript error", error);
}
})();

For network requests:

fetch("https://example.com/endpoint")
.then(function (response) {
if (!response.ok) {
throw new Error("Request failed with status " + response.status);
}
return response.json();
})
.then(function (data) {
console.log("Received data", data);
})
.catch(function (error) {
console.warn("Request failed", error);
});

If your script adds DOM elements, add a marker attribute and check for it before creating another element.

if (document.querySelector("[data-custom-footer]")) {
return;
}
const footer = document.createElement("div");
footer.setAttribute("data-custom-footer", "true");
footer.textContent = "Custom footer";

This prevents duplicate banners, duplicate buttons, and duplicate event widgets.

Avoid creating global variables like this:

var customerName = "Acme";

Prefer scoped variables inside a wrapper:

(function () {
var customerName = "Acme";
console.log(customerName);
})();

Avoid selectors that depend on internal generated class names:

document.querySelector(".css-1abcxyz");

Generated class names can change after product updates or rebuilds.

Prefer stable selectors:

document.querySelector('[data-notionapps-end-user-root="true"]');

If you must target a specific element, inspect the page carefully and choose the least fragile selector available.

Follow these practices when authoring Custom JavaScript:

  • Review every script before publishing.
  • Do not paste code from unknown sources.
  • Do not include API keys, passwords, tokens, or secrets.
  • Do not collect personal data without approval.
  • Do not send data to unapproved third-party services.
  • Do not use Custom JavaScript to hide sensitive fields.
  • Do not change authentication or authorization behavior in the browser.
  • Keep scripts short and focused.
  • Test scripts in a non-production app first.

Custom JavaScript runs in the user’s browser, so performance matters.

Use these guidelines:

  • Keep scripts small.
  • Avoid long loops over large DOM trees.
  • Avoid frequent timers such as very short setInterval calls.
  • Avoid heavy work in MutationObserver callbacks.
  • Load external scripts only when needed.
  • Use event delegation instead of adding many individual event listeners.
  • Fail gracefully if an external service is unavailable.

If your script adds UI elements:

  • Use semantic elements such as button for clickable actions.
  • Make sure buttons have visible text or accessible labels.
  • Ensure custom elements can be used with a keyboard.
  • Use sufficient color contrast.
  • Do not trap keyboard focus.
  • Do not remove focus outlines unless you replace them with an accessible focus style.

Example accessible button:

const button = document.createElement("button");
button.type = "button";
button.textContent = "Dismiss message";
button.setAttribute("aria-label", "Dismiss message");

After saving and publishing, open the published app and use browser DevTools.

Add a log statement:

console.log("Custom JavaScript loaded");

Then check the Console for:

Custom JavaScript loaded

Add an attribute:

document.documentElement.setAttribute("data-custom-js-loaded", "true");

Then run this in the Console:

document.documentElement.getAttribute("data-custom-js-loaded");

Expected result:

true

If your script adds an element, inspect the page and search for the marker attribute.

Example:

document.querySelector("[data-brand-footer]");

If the result is an element, the script added it successfully.

Check the following:

  • Custom JavaScript is enabled.
  • The app was saved after editing the script.
  • The app was re-published.
  • The published app was hard-refreshed.
  • The browser Console does not show a syntax error.
  • The script contains a console.log verification message.

The Script Runs but Cannot Find the App Root

Section titled “The Script Runs but Cannot Find the App Root”

The app root may not be available yet.

Use a wait helper:

function waitForElement(selector, callback) {
const element = document.querySelector(selector);
if (element) {
callback(element);
return;
}
const observer = new MutationObserver(function () {
const element = document.querySelector(selector);
if (element) {
observer.disconnect();
callback(element);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}

Add a marker attribute and check for it:

if (document.querySelector("[data-custom-banner]")) {
return;
}

The Script Works Once but Breaks After Navigation

Section titled “The Script Works Once but Breaks After Navigation”

The app may re-render content after navigation or screen changes. Use event delegation or MutationObserver.

Event delegation example:

const root = document.querySelector('[data-notionapps-end-user-root="true"]');
if (root) {
root.addEventListener("click", function (event) {
const button = event.target.closest("button");
if (button) {
console.log("Button clicked");
}
});
}

Wrap the script in try...catch:

(function () {
try {
// Custom code goes here.
} catch (error) {
console.error("Custom JavaScript error", error);
}
})();

Check:

  • The URL is correct.
  • The endpoint supports HTTPS.
  • The endpoint allows browser requests.
  • CORS is configured correctly.
  • The request does not require secret credentials.
  1. Start with a verification script.
  2. Publish and confirm the script runs.
  3. Add the smallest useful behavior.
  4. Wrap the script in an IIFE.
  5. Add error handling.
  6. Scope DOM changes to the end-user root.
  7. Add marker attributes for injected elements.
  8. Test on desktop and mobile.
  9. Test after navigation or screen changes.
  10. Re-publish and verify the final script.
Goal Pattern
Verify script runs console.log("Custom JavaScript loaded")
Find app root document.querySelector('[data-notionapps-end-user-root="true"]')
Avoid global scope (function () { ... })();
Catch errors try { ... } catch (error) { ... }
Read query params new URLSearchParams(window.location.search)
Add an element document.createElement("div")
Prevent duplicates document.querySelector("[data-custom-element]")
Listen for clicks root.addEventListener("click", callback)
Watch dynamic changes new MutationObserver(callback)
Store non-sensitive preference window.localStorage.setItem(key, value)

Use this as a starting point for most Custom JavaScript snippets:

(function () {
try {
const rootSelector = '[data-notionapps-end-user-root="true"]';
function run(root) {
console.log("Custom JavaScript loaded");
// Add custom behavior here.
}
const existingRoot = document.querySelector(rootSelector);
if (existingRoot) {
run(existingRoot);
return;
}
const observer = new MutationObserver(function () {
const root = document.querySelector(rootSelector);
if (root) {
observer.disconnect();
run(root);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
window.setTimeout(function () {
observer.disconnect();
}, 10000);
} catch (error) {
console.error("Custom JavaScript error", error);
}
})();

Custom JavaScript in NotionApps is a flexible way to add small client-side enhancements to published apps. Use it carefully, keep scripts focused, and test thoroughly before publishing. For most use cases, start with a scoped script wrapper, target the end-user root, add error handling, and verify the result in browser DevTools.

This guide teaches a focused maker workflow. For the full screen option inventory (What it does / How to use / Why) and annotated builder screenshots, use the matching Types of Screens guide:

  • Change one option family at a time, then publish and test on phone and desktop.
  • Prefer screen-level settings in Content / Behaviour / Appearance before custom CSS/JS.
  • Keep public access and navigation visibility intentional on every screen you link from this guide.