
Process Spotlight: How We Do JavaScript at Mostly Serious
Let’s face it—not all of us are developing single‑page applications (SPAs). If you’re building with Craft CMS or another server‑rendered platform, you’re probably leaning on Twig and PHP to do the heavy lifting on the server while still delivering highly interactive UI with JavaScript.
That combination can be powerful—but it also comes with responsibility. In the words of Uncle Ben (or Aunt May, depending on your Spider‑Man), “With great power comes great responsibility.” As developers, it’s on us to use that scripting power wisely and keep our sites fast, lightweight, and maintainable.
Loading JavaScript is easy. Loading it responsibly so users never feel the weight of our decisions is the real work.
In this Process Spotlight, we’ll walk through how we organize JavaScript at Mostly Serious to avoid a single 400KB bundle, keep selectors explicit, and make it easier for the whole team—especially new developers—to reason about what’s happening on a page.
The slower way — statically import all the things
First, let’s look at the pattern most of us start with: a single entry file that imports every module up front and initializes them on DOM ready.
// src/js/index.js
import intro from './modules/intro';
import popup from './modules/popup';
import slider from './modules/slider';
import toggle from './modules/toggle';
import example from './modules/example';
import reframe from './modules/reframe';
import foobar from './modules/foobar';
import accordion from './modules/accordion';
import formLabels from './modules/form-labels';
import lazyLoad from './modules/lazy-load';
(ready => {
if (document.readyState !== 'loading') {
ready();
} else {
document.addEventListener('DOMContentLoaded', ready);
}
})(() => {
intro(document.querySelectorAll('.intro'));
popup(document.querySelectorAll('#popup'));
slider(document.querySelectorAll('.slider-wrapper'));
toggle(document.querySelectorAll('.toggle, .random-callout, .modal-trigger'));
example(document.querySelectorAll('.example'));
reframe(document.querySelectorAll('.media-frame, .special-module #video-frame'));
foobar(document.querySelectorAll('#foo, .callout .item'));
accordion(document.querySelectorAll('.accordion'));
formLabels(document.querySelectorAll('form'));
lazyLoad(document.querySelectorAll('.lazy-load'));
// ...
});This approach is simple and familiar. All of your dependencies live at the top, and everything gets initialized in one place once the DOM is ready.
But under the hood, it creates two big problems that show up over time: static imports that produce a single, ever‑growing bundle and generic selectors that blur the line between styling and behavior.
Static imports
When every module is statically imported into your main entry file, the result is a massive JavaScript bundle—often thousands of lines and hundreds of kilobytes—that has to be downloaded, parsed, and executed on every page view, whether the page needs all that logic or not.
Even tiny changes can force browsers to re‑download the entire file. On sites with long‑lived content and frequent small updates, that’s a recipe for wasted bandwidth and slower perceived performance.
Generic selectors
The selectors in that example are all technically valid, but they’re also dangerously generic. They mix arbitrary classes, tags, and IDs in ways that don’t communicate intent and tightly couple your styling to your behavior.
Consider this line:
foobar(document.querySelectorAll('#foo, .callout .item'));If a future change removes the .item class from a callout—or moves that markup into a different container—you’ve just broken the foobar() behavior in a way that’s hard to spot from the JavaScript alone. For a new developer walking into the project, those kinds of hidden dependencies are landmines.
Over time, static imports and generic selectors make projects harder to debug, harder to refactor, and harder to work on as a team.
The faster way — conditionally import some of the things
Now let’s look at the pattern we use instead: a small, focused entry file and an init module that conditionally loads what the current page actually needs.
// src/js/index.js
import init from './init';
(ready => {
if (document.readyState !== 'loading') {
ready();
} else {
document.addEventListener('DOMContentLoaded', ready);
}
})(() => {
init(document);
// Any additional global code…
});// src/js/init.js
const modules = {
'.intro': () => import('./modules/intro'),
'[data-popup]': () => import('./modules/popup'),
'[data-slider]': () => import('./modules/slider'),
'[data-toggle]': () => import('./modules/toggle'),
'[data-example]': () => import('./modules/example'),
'[data-reframe]': () => import('./modules/reframe'),
'[data-controls]': () => import('./modules/controls'),
'[data-accordion]': () => import('./modules/accordion'),
'[data-form-labels]': () => import('./modules/form-labels'),
'[data-lazy-load]': () => import('./modules/lazy-load')
};
export default (scope) => {
Object.keys(modules).forEach((selector) => {
const request = modules[selector];
const els = scope.querySelectorAll(selector);
if (els && els.length) {
request().then(({ default: module }) => module(els));
}
});
};Dynamic imports
If you’re new to dynamic imports, the key difference is timing. Static imports are pulled into the bundle immediately at build time. Dynamic import() calls load additional JavaScript at execution time, asynchronously, and only when certain conditions are met.
By moving our module registrations into init.js and using dynamic imports, the browser only downloads code for modules that actually match something in the current DOM. No slider on the page? No slider JavaScript to download.
Data attribute selectors
You’ll also notice that most of our selectors switched to data attributes. That’s intentional. Data attributes give us a clear, semantic signal in the markup that a piece of JavaScript behavior is attached to this element.
Our styling can continue to use classes and IDs however it wants, while our JavaScript looks for explicit, purpose‑built hooks.
{# _example.twig #}
<div data-example data-example-argument="{{ someVariable }}"></div>// src/js/modules/example.js
export default (els) => {
// This code will now only get fetched, parsed,
// and executed on pages that actually use it.
Array.prototype.forEach.call(els, (el) => {
const arg = el.dataset.exampleArgument;
console.log(arg);
});
};With this setup, it’s easy to scan a template and immediately see which components have JavaScript behavior, what arguments they receive, and where those arguments come from.
Going a little further
All of this works great for vanilla JavaScript utilities—but what about more complex UI? On a traditional multi‑page application (MPA), you don’t always want (or need) to drop a full front‑end framework like React or Vue on every page.
You could wire up a client‑side framework to sit on top of your server‑rendered templates, or reach for HTML‑over‑the‑wire tools like Livewire. At Mostly Serious, we often reach for Svelte instead.
Svelte compiles components down to plain JavaScript at build time. There’s no separate runtime bundle to load before your code can run, which keeps payloads small and performance snappy—especially when paired with the dynamic import pattern above.
Dynamically importing Svelte components
To make Svelte play nicely with our Craft templates and init pattern, we introduce a small custom element wrapper called x‑svelte. It gives us a consistent, explicit hook for mounting Svelte components and passing data and slots through.
{# _base.twig #}
<head>
<script>
window.customElements.define('x-svelte', class extends HTMLElement {});
</script>
<style>
x-svelte { display: contents; }
</style>
</head>Because x‑svelte is set to display: contents, it doesn’t interfere with layout—it simply acts as a semantic container for the component we’re about to mount.
{# _example.twig #}
<x-svelte component="example" data-block-id="{{ block.id }}">
<template>
<h2>Hello, from _example.twig.</h2>
</template>
</x-svelte>The component attribute tells our JavaScript which Svelte module to load, and any data‑* attributes become props on the component. Templates inside x‑svelte are passed through as slots.
Extending init.js for Svelte
// src/js/init.js
const modules = {
// ...
'x-svelte': () => import('./svelte')
};By giving x‑svelte its own entry in the modules map, we make sure the Svelte adapter is only loaded on pages that actually use Svelte components.
The Svelte adapter
The Svelte adapter handles three jobs: figuring out which Svelte component to load, wiring up a shared store, and translating any <template> children into Svelte slots.
// src/js/svelte.js
import { detach, insert, noop } from 'svelte/internal';
export default (els) => {
const store = () => import('./store');
const components = {
example: () => import('./components/Example.svelte')
};
els.forEach((target) => {
const componentName = target.getAttribute('component');
const request = components[componentName];
if (!request) return;
Promise.all([request(), store()]).then(([{ default: Component }, { default: store }]) => {
const slots = {};
const slotElements = target.querySelectorAll(':scope > template');
slotElements.forEach((el) => {
const name = el.getAttribute('slot');
slots[name || 'default'] = el.content;
});
target.innerHTML = '';
new Component({
target,
store,
props: {
...target.dataset,
$$slots: createSlots(slots),
$$scope: {}
}
});
});
});
};
function createSlots(templates) {
const slots = {};
for (const name in templates) {
if (Object.prototype.hasOwnProperty.call(templates, name)) {
slots[name] = [createSlot(templates[name])];
}
}
function createSlot(element) {
const nodes = [...element.childNodes];
return function () {
return {
c: noop,
l: noop,
m(target, anchor) {
Array.prototype.forEach.call(nodes, (node) => {
insert(target, node, anchor);
});
},
d(detaching) {
if (detaching) {
Array.prototype.forEach.call(nodes, (node) => {
detach(node);
});
}
}
};
};
}
return slots;
}The exact implementation details aren’t as important as the pattern: Svelte components are loaded on demand, wired up to a shared store, and given access to both dataset props and template slots from the surrounding markup.
Using Svelte with slots and data attributes
// src/js/components/Example.svelte
<slot><!-- Content: <h2>Hello, from _example.twig.</h2> --></slot>
<ul>
{#each block.items as item (item.id)}
<li>{item.title}</li>
{/each}
</ul>
<script>
export let blockId;
let block = { items: [] };
fetch(`/api/example/${blockId}`)
.then((res) => res.json())
.then((res) => (block = res));
</script>Here, Svelte pulls in the default slot content from Twig, uses the data‑block‑id attribute as a prop, and fetches the rest of the data it needs from an API endpoint.
Reusing vanilla modules inside Svelte
One of our favorite benefits of this pattern is that we can reuse the same vanilla modules inside Svelte components by calling init() on a smaller scope.
{# templates/modules/blocks/_newExample.twig #}
<x-svelte component="new-example">
<template>
<div data-example data-example-argument="{{ someVariable }}"></div>
</template>
</x-svelte>// src/js/components/NewExample.svelte
<div bind:this={scope}>
<slot><!-- Content: <div data-example data-example-argument="..."></div> --></slot>
</div>
<script>
import init from '../init';
import { onMount } from 'svelte';
let scope;
onMount(() => {
init(scope);
});
</script>Because init() accepts a scope, we can safely re‑run any vanilla JavaScript modules inside the part of the DOM Svelte controls—without racing against the rest of the page or double‑initializing everything.
Why this pattern works for us
These techniques let us take full advantage of Twig in Craft CMS while still delivering complex, dynamic UI where it matters.
- Our front‑end stays lightweight because only the JavaScript a page actually needs is ever downloaded.
- Our selectors stay explicit thanks to data attributes that make behavior easy to spot in templates.
- Our team stays faster because Svelte components slot neatly into the same init pattern instead of inventing a second, parallel architecture.
Most importantly, this approach scales. Whether you’re shipping a small marketing site or a long‑lived product, dynamic imports and clear selectors help your future self—and your teammates—move quickly without turning every change into a performance gamble. Uncle Ben would be proud.