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.
Who This Guide Is For
Section titled “Who This Guide Is For”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.
What Custom JavaScript Can Do
Section titled “What Custom JavaScript Can Do”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.
What Custom JavaScript Should Not Do
Section titled “What Custom JavaScript Should Not Do”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 You Begin
Section titled “Before You Begin”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.
Add Custom JavaScript in the Builder
Section titled “Add Custom JavaScript in the Builder”To add Custom JavaScript:
- Open the app in the NotionApps builder.
- Go to the app settings area.
- Find the Custom JavaScript section.
- Enable Custom JavaScript.

- Paste your JavaScript into the editor.
- Save the app.
- Publish or re-publish the app.
- Open the published end-user app.
- Verify the script in browser DevTools.
Understand Where the Script Runs
Section titled “Understand Where the Script Runs”Custom JavaScript runs in the browser for the end-user app.
This means:
- It can access browser APIs such as
document,window, andlocalStorage. - 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_onlyThis means the script is intended to run only in the rendered end-user app.
Start with a Verification Script
Section titled “Start with a Verification Script”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 loadedYou can also run this in the Console:
document.documentElement.getAttribute("data-custom-js-loaded");Expected result:
trueUse a Safe Script Wrapper
Section titled “Use a Safe Script Wrapper”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.
Target the End-User Root Element
Section titled “Target the End-User Root Element”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;}Wait for the App Root
Section titled “Wait for the App Root”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.
Avoid Depending Only on the Load Event
Section titled “Avoid Depending Only on the Load Event”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.
Example: Add a Footer Note
Section titled “Example: Add a Footer Note”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.
Example: Add a Help Banner
Section titled “Example: Add a Help Banner”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); }})();Example: Read URL Parameters
Section titled “Example: Read URL Parameters”This example reads a query parameter from the URL and logs it.
URL:
https://example.notionapps.com/?source=client-emailJavaScript:
(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;}Example: Send a Basic Analytics Event
Section titled “Example: Send a Basic Analytics Event”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.
Example: Track Button Clicks
Section titled “Example: Track Button Clicks”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.
Example: Observe Dynamic Changes
Section titled “Example: Observe Dynamic Changes”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.
Example: Add a Back-to-Top Button
Section titled “Example: Add a Back-to-Top Button”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);})();Example: Load an External Script
Section titled “Example: Load an External Script”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);})();Error Handling
Section titled “Error Handling”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); });Avoid Duplicate Elements
Section titled “Avoid Duplicate Elements”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 Global Variables
Section titled “Avoid Global Variables”Avoid creating global variables like this:
var customerName = "Acme";Prefer scoped variables inside a wrapper:
(function () { var customerName = "Acme"; console.log(customerName);})();Avoid Fragile Selectors
Section titled “Avoid Fragile Selectors”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.
Security Best Practices
Section titled “Security Best Practices”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.
Performance Best Practices
Section titled “Performance Best Practices”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
setIntervalcalls. - Avoid heavy work in
MutationObservercallbacks. - Load external scripts only when needed.
- Use event delegation instead of adding many individual event listeners.
- Fail gracefully if an external service is unavailable.
Accessibility Best Practices
Section titled “Accessibility Best Practices”If your script adds UI elements:
- Use semantic elements such as
buttonfor 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");Verify That Custom JavaScript Worked
Section titled “Verify That Custom JavaScript Worked”After saving and publishing, open the published app and use browser DevTools.
Check the Console
Section titled “Check the Console”Add a log statement:
console.log("Custom JavaScript loaded");Then check the Console for:
Custom JavaScript loadedCheck a DOM Attribute
Section titled “Check a DOM Attribute”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:
trueCheck Added Elements
Section titled “Check Added Elements”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.
Troubleshooting
Section titled “Troubleshooting”The Script Does Not Run
Section titled “The Script Does Not Run”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.logverification 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 });}The Script Adds Duplicate Elements
Section titled “The Script Adds Duplicate Elements”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"); } });}The Script Causes a Console Error
Section titled “The Script Causes a Console Error”Wrap the script in try...catch:
(function () { try { // Custom code goes here. } catch (error) { console.error("Custom JavaScript error", error); }})();An External Request Fails
Section titled “An External Request Fails”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.
Recommended Authoring Workflow
Section titled “Recommended Authoring Workflow”- Start with a verification script.
- Publish and confirm the script runs.
- Add the smallest useful behavior.
- Wrap the script in an IIFE.
- Add error handling.
- Scope DOM changes to the end-user root.
- Add marker attributes for injected elements.
- Test on desktop and mobile.
- Test after navigation or screen changes.
- Re-publish and verify the final script.
JavaScript Pattern Reference
Section titled “JavaScript Pattern Reference”| 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) |
Starter Template
Section titled “Starter Template”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); }})();Summary
Section titled “Summary”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.
Builder options at a glance
Section titled “Builder options at a glance”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:
- Data screens: List (View Items), Details, Form (Add Item), Form (Update One Item), List (Update Items), Content
- Everyday chrome / Queue & activity: see the Types of Screens index
- Automation native screens: Native Automation Screens
Recommended defaults
Section titled “Recommended defaults”- 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.