Esc

Best Practices

Patterns and conventions that keep Streak.js projects maintainable, performant, and bug-free.


Always Use Optional Chaining and Nullish Coalescing

Widget props.data is undefined when the handler returns no entry for that widget. Always guard access:

// WRONG — throws if props.data is undefined
const heading = props.data.heading;

// CORRECT
const heading = props?.data?.heading ?? "Default Heading";

This applies to every field you read from props.data.


Pass Server Values Through Script Options

Script children are serialized with .toString(). Closures do not survive — variables from the outer scope are not available at runtime.

// WRONG — accentColor is undefined in the browser
const accentColor = props?.data?.accentColor ?? "#818cf8";
<Script id="banner-init">
  {(gDom: any) => {
    document.getElementById("el").style.color = accentColor; // undefined
  }}
</Script>

// CORRECT — pass through options
<Script id="banner-init" options={{ color: props?.data?.accentColor ?? "#818cf8" }}>
  {(gDom: any, options: any) => {
    document.getElementById("el").style.color = options.color;
  }}
</Script>

The options object is JSON-serialized and passed as the second IIFE argument. Any value that needs to cross the build-to-browser boundary must go through options.


Use Stable DOM IDs for Script References

Script functions reference DOM elements by ID. Always set explicit id attributes on elements your scripts need:

<h1 id="hero-heading">{heading}</h1>
<Script id="hero-init">
  {(gDom: any) => {
    const el = document.getElementById("hero-heading");
    if (el) el.style.opacity = "1";
  }}
</Script>

Avoid dynamic or generated IDs that could change between builds.


Add { passive: true } to Scroll and Mousemove Listeners

Passive event listeners allow the browser to scroll without waiting for your handler to finish. Always pass { passive: true } for scroll, mousemove, touchstart, and touchmove events:

window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("mousemove", handleMove, { passive: true });

This prevents jank and improves Lighthouse scores.


Keep Widgets Stateless

Widgets are rendered once at build time. There is no browser-side re-render. Do not use useState, useEffect, or any React hooks:

// WRONG — hooks do not work in Streak widgets
const MyWidget = (props) => {
  const [count, setCount] = useState(0); // will not work
  return <div>{count}</div>;
};

// CORRECT — pure render function
const MyWidget = (props) => {
  const label = props?.data?.label ?? "Click me";
  return <button id="my-btn">{label}</button>;
};

All client-side interactivity belongs in Script components.


Use Unique renderIds

Every sitemap entry must have a globally unique renderId. It identifies the entry throughout the render pipeline and is written into its build output — reusing one across entries makes that output ambiguous:

[
  { "url": "/", "renderConfig": { "renderId": "home", ... } },
  { "url": "/about", "renderConfig": { "renderId": "about", ... } }
]

Match Widget IDs Exactly

A widget's id and type are two independent values, each of which must line up across several places. A mismatch in any one causes the widget to silently fail. id and type do not have to equal each other.

id — the instance key — must be identical in:

LocationFieldExample
streak.sitemap.jsonwidgets[].id"id": "helloBanner"
Layout<WidgetPlaceholder id= /><WidgetPlaceholder id="helloBanner" ... />
Handler returnObject key{ helloBanner: { heading: "Hello" } }

type — the component name — must be identical in:

LocationFieldExample
streak.sitemap.jsonwidgets[].type"type": "HelloBanner"
Layout<WidgetPlaceholder type= /><WidgetPlaceholder ... type="HelloBanner" />
Widget fileFilenamesrc/widgets/HelloBanner.tsx

All values are case-sensitive. Each id must be unique within a page's widgets[]; the same type can be reused by several entries on one page (each with its own id) to place the same component in multiple spots.


Remove Event Listeners Before Adding (When Outside Script Blocks)

The runtime automatically cleans up every window/document event listener registered inside a Script block when the user navigates to a new page — so for normal widget scripts, no manual teardown is needed.

The pattern matters when you register listeners outside that managed scope: inside a sf:pageload handler, inside a third-party library callback, or on elements that persist across navigations (<html>, a sticky header, a fixed overlay). In those cases, always remove before adding to prevent accumulation across navigations:

// WRONG — a new listener is stacked on every SPA navigation
window.addEventListener("sf:pageload", () => {
  window.addEventListener("scroll", updateProgressBar, { passive: true });
});

// CORRECT — remove previous registration before adding
window.addEventListener("sf:pageload", () => {
  window.removeEventListener("scroll", updateProgressBar);
  window.addEventListener("scroll", updateProgressBar, { passive: true });
});

removeEventListener is a no-op if the listener was never added, so it is always safe to call first regardless. For full details on the SPA lifecycle events, see SPA Events.


Put Third-Party JS in public/assets/js/

Libraries loaded via gDom.loadPackage() must be present at public/assets/js/. These files are fetched at runtime by injecting a <script>/<link> tag pointed at /assets/<path> and are not generated by any build step.

Commit them to git. They are part of your site's runtime dependencies and must be available in production:

public/
  assets/
    js/
      motion.js       ← commit this
      lenis.min.js    ← commit this

If a file is missing from public/assets/, loadPackage will resolve but the library global will be undefined.