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. Streak uses it as the output directory name — if two entries share a renderId, one will overwrite the other:

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

Match Widget IDs Exactly

The widget id and type must match across three places. A mismatch in any one causes the widget to silently fail:

LocationFieldExample
streak.sitemap.jsonwidgets[].id and widgets[].type"id": "HelloBanner", "type": "HelloBanner"
Layout<WidgetPlaceholder id= type= /><WidgetPlaceholder id="HelloBanner" type="HelloBanner" />
Handler returnObject key{ HelloBanner: { heading: "Hello" } }
Widget fileFilenamesrc/widgets/HelloBanner.tsx

All four values are case-sensitive and must match exactly.


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 the asset worker 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.