Skip to main content

App Scripts

App scripts are JavaScript files your app declares in app.json that the storefront loads on every page — no theme edits, no merchant action beyond installing the app. The host inserts a non-blocking loader in content_for_header that fetches /api/apps/extensions and injects every declared script via requestIdleCallback. Use app scripts when your app needs to run on the storefront and the effect is delivered entirely by a <script> (chat widgets, pixels, typeahead overlays, floating buttons, badge swaps, etc.). For Liquid placement merchants drag into theme sections, use Storefront Blocks. For sandboxed customer-event listeners, use Web Pixels.

When to Use App Scripts

NeedUse
Floating chat / WhatsApp / Calendly button on every pageApp script
GA4 / Meta Pixel / TikTok / Hotjar / ClarityApp script
Storefront-wide search typeahead, points pill, pre-order badgeApp script
Liquid block the merchant places inside a sectionStorefront Block
Sandboxed listener for page_viewed, cart_updated, etc.Web Pixel
<head> / </body> overlay the merchant can toggle per-themeStorefront Embed
The differentiator: app scripts load automatically on every storefront page the moment the app is installed. The merchant doesn’t have to drop a block, toggle a switch, or edit the theme.

How It Works

  1. Your app’s app.json declares one or more appScripts entries with a src URL.
  2. After install, the storefront fetches /api/apps/extensions?domainSlug=… on every page. The response includes a scripts[] array merged from the backend (DB-backed installs) and local manifests on disk.
  3. A small loader stub in content_for_header schedules requestIdleCallback (falls back to setTimeout(0)), fetches the extensions list, and appends one <script> per entry — de-duplicated by src so SPA navigations don’t double-inject.
  4. Your script runs on the storefront, scoped to that merchant’s domain.
The loader is built into the storefront’s render pipeline — you don’t need to wire anything yourself. Declaring appScripts in app.json is enough.

Manifest

Declare app scripts under extensions.appScripts in your app.json:
{
  "handle": "early-access",
  "name": "Early Access — Pre-orders",
  "version": "1.0.0",
  "extensions": {
    "appScripts": [
      {
        "id": "early-access-loader",
        "src": "/api/apps/early-access/loader",
        "loadStrategy": "defer",
        "position": "head"
      }
    ]
  }
}

Fields

FieldTypeDefaultDescription
idstringUnique identifier within your app. Surfaced in the loader response and on the injected <script data-app-id="…"> tag for easy debugging.
srcstringURL the loader appends as <script src="…">. May be a relative path on the host (e.g. /api/apps/<handle>/loader) or an absolute external URL. Required.
loadStrategy"defer" | "async""defer"Maps directly to the <script> attribute. Use defer when your code touches the DOM, async for fire-and-forget pixels.
position"head" | "body""body"Where the loader appends the script tag. head runs marginally earlier; body is fine for most widgets.
appIdstring<appHandle>Optional override surfaced on the injected <script data-app-id> attribute.
configobject{}Reserved for future per-script merchant config. Not consumed by the current loader.

Multiple scripts per app

Declare more than one entry if your app needs to inject a separate file at a different position or load strategy:
{
  "extensions": {
    "appScripts": [
      { "id": "tracker",  "src": "/api/apps/my-app/tracker",  "loadStrategy": "async", "position": "head" },
      { "id": "widget",   "src": "/api/apps/my-app/widget",   "loadStrategy": "defer", "position": "body" }
    ]
  }
}
Both are injected on every storefront page. The loader de-duplicates by src, so reloading or SPA-navigating won’t double-load.

The loader.js Endpoint

The src URL almost always points at an endpoint inside your app — so the script body can read the merchant’s saved settings and bake them in. The endpoint must respond with Content-Type: application/javascript.
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
res.setHeader("Cache-Control", "public, max-age=60");
res.setHeader("Access-Control-Allow-Origin", "*");
The 60-second cache balances freshness with throughput. If the merchant disables the app in admin, the storefront picks up the new disabled state within a minute.

Disabled / unconfigured state

Return a no-op comment when the merchant hasn’t enabled the app yet — the loader still appends a <script> tag, but it does nothing.
if (!cfg.enabled) {
  return res.status(200).send(`/* my-app: disabled for ${escapeJs(slug)} */`);
}
Don’t return 4xx — the loader treats non-200 as an error and you lose the ability to debug from a browser request.

Resolving the merchant slug

The loader passes domainSlug as a query string, but you should also accept the x-domain-slug header and the request Host:
const slug =
  req.query.domainSlug ||
  req.headers["x-domain-slug"] ||
  (req.headers.host || "").split(".")[0] ||
  "";

Minimal Example

A complete script-type app has two parts — the listing bundle and the HTTP endpoints your app serves:
Listing bundle:
├── app.json
├── icon.svg
└── admin/app-home.html

Endpoints served by your app:
├── /config      # GET/POST merchant config
├── /loader      # Returns the storefront IIFE
└── /flags       # GET — per-product flags read by the loader

app.json

{
  "id": "early-access",
  "handle": "early-access",
  "name": "Early Access — Pre-orders",
  "vendor": "LaunchMyStore",
  "version": "1.0.0",
  "summary": "Turn any product into a pre-order — no theme edits.",
  "categories": ["marketing-and-conversion"],
  "icon": "https://cdn.simpleicons.org/rocket/F59E0B",
  "adminHome": "admin/app-home.html",
  "extensions": {
    "appScripts": [
      {
        "id": "early-access-loader",
        "src": "/api/apps/early-access/loader",
        "loadStrategy": "defer",
        "position": "head"
      }
    ]
  }
}

loader.js

The loader returns an IIFE that runs on the storefront. Bake the merchant slug and any safe config values into the response so the script doesn’t need to make a second round trip just to know which store it’s on.
import { readConfig } from "./config";

function escapeJs(s) {
  return String(s || "").replace(/[\\'"<>]/g, (c) => ({
    "\\": "\\\\", "'": "\\'", '"': '\\"', "<": "\\u003c", ">": "\\u003e",
  })[c]);
}

export default function handler(req, res) {
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
  res.setHeader("Cache-Control", "public, max-age=60");
  res.setHeader("Access-Control-Allow-Origin", "*");

  const slug =
    req.query.domainSlug ||
    req.headers["x-domain-slug"] ||
    (req.headers.host || "").split(".")[0] ||
    "";
  if (!slug) return res.status(200).send("/* early-access: no slug */");

  const cfg = readConfig(slug);
  if (!cfg.enabled) {
    return res.status(200).send(`/* early-access: disabled */`);
  }

  const slugJs = escapeJs(slug);

  return res.status(200).send(`/*! early-access for ${slugJs} */
(function(){
  if (window.__earlyAccess) return;
  window.__earlyAccess = { installed: true };

  function start() {
    var path = location.pathname || '';
    var m = path.match(/\\/products\\/([^/?#]+)/);
    if (!m) return;
    var handle = decodeURIComponent(m[1]);

    fetch('/api/apps/early-access/flags?domainSlug=${slugJs}')
      .then(function(r){ return r.ok ? r.json() : null; })
      .then(function(j){
        if (!j || !j.enabled || !j.flags || !j.flags[handle]) return;
        // mutate DOM here — relabel Add-to-cart, append a badge, etc.
      })
      .catch(function(){});
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start, { once: true });
  } else {
    start();
  }
})();`);
}

config.js

Stores the merchant’s settings keyed by store slug. Keep it simple: readConfig(slug) / writeConfig(slug, cfg) with the slug normalised (never trust it as a path segment) and a short in-memory cache in front of your store.

Verifying The Loader Is Wired

Two HTTP calls confirm everything is connected end-to-end:
# 1. Your loader returns valid JS for this merchant
curl 'https://acme-store.launchmystore.io/api/apps/early-access/loader?domainSlug=acme-store' \
  -H 'Accept: application/javascript'

# 2. /api/apps/extensions surfaces it in scripts[]
curl 'https://acme-store.launchmystore.io/api/apps/extensions?domainSlug=acme-store' \
  | jq '.scripts[] | select(.appHandle == "early-access")'
The second call should return an entry like:
{
  "appId": "early-access",
  "appHandle": "early-access",
  "id": "app-script-early-access-loader",
  "src": "/api/apps/early-access/loader",
  "loadStrategy": "defer",
  "position": "head",
  "config": {}
}
If scripts[] doesn’t contain your entry, the local manifest cache may be stale — touch the app.json or wait 60s.

CSP & Cross-Origin Notes

The loader injects <script src> tags directly into the storefront page, so the merchant’s storefront origin must allow the script’s origin. For scripts served by your own host (relative paths under /api/apps/…) this is automatic. For absolute external URLs, the merchant’s CSP — if any — must list the script host in script-src. If you’re injecting a third-party vendor snippet (e.g. Tawk, Clarity, Hotjar), it’s safer to have your loader return a stub that appends the vendor script itself rather than redirecting to the vendor URL — that way you control caching and can disable the integration without the merchant changing their CSP.

Canonical Examples

Several first-party apps in the LaunchMyStore catalog use this pattern and make a good reference:
  • Channels Hub — 4-in-1 chat aggregator (Tawk + Intercom + Crisp + WhatsApp). Loader emits each vendor snippet only when its credential is set.
  • Smart Pixel Manager — Loads GA4 + Meta + TikTok with consent gating wired through a storefront cookie.
  • Early Access — Per-product pre-order labels.
  • Restock Alerts — In-page “Notify me” form on sold-out variants.
  • Cobalt Search — Search typeahead overlay attached to the theme’s search input.

See Also

  • Extensions Overview — full manifest reference.
  • Storefront Blocks — Liquid blocks merchants place inside theme sections (different surface, different lifecycle).
  • Web Pixels — sandboxed customer-event scripts for page_viewed, cart_updated, checkout_completed.
  • App Bridge Overview — the postMessage contract used by admin/checkout iframes (app scripts run in-page and don’t need App Bridge).