JS Module Architecture
JavaScript structure affects maintainability, testability, and what users download.
01 — Purpose
Structure that scales with the team
JavaScript architecture affects maintainability, testability, and what users download — regardless of framework.
One giant bundle with hidden globals does not scale past a handful of developers. Module boundaries, loading only what each page needs, and progressive enhancement keep behaviour understandable and performance predictable — whether you ship static HTML, a multi-page app, or a client-rendered SPA.
See JavaScript standard and JavaScript cost for delivery expectations.
02 — Principles
Modular and understandable
Code should be reusable, testable, and isolated — not centralised by default.
- ES modules with explicit imports — dependencies visible at the top
- one clear responsibility per module — header nav, syntax highlighting, form validation
- progressive enhancement — script enhances what HTML already does
- independently testable units — pure functions where possible
03 — Practice
Module patterns that work
Load less, load later, and initialise once — on any stack.
-
include scripts only on pages that need them — not one
app.json every template -
use
type="module"anddeferfor enhancement scripts — parse after HTML, run in order, without blocking render -
use dynamic
import()for heavy optional features — syntax highlighters, charts, maps, admin-only tools - guard initialisation — skip if DOM nodes are missing or already bound
- avoid global state — pass configuration or read from DOM data attributes
- co-locate small modules with the feature or component that uses them
How you load scripts
Default enhancement scripts to native ES modules with defer. The browser downloads them in parallel with parsing, executes them
after the document is parsed, and preserves order between deferred
scripts — a good fit for progressive enhancement without blocking
first paint.
<script type="module" src="/scripts/site-header.js" defer></script>
<!-- Page-specific module — only on templates that need it -->
<script type="module" src="/scripts/checkout.js" defer></script>
Use async only when order does not matter — analytics
snippets, independent widgets. Do not sprinkle async on
modules that depend on each other or on DOM readiness unless you
handle that explicitly inside the module.
Per-page and per-feature modules
Split by route, layout, or feature — header behaviour on layouts with a header; validation on forms; nothing on purely static articles. In React, Vue, Svelte, or similar apps, the same idea applies as route-based code splitting: the shell loads once; route chunks load when navigated to.
Dynamic import for optional weight
If a feature is heavy and not everyone needs it, load it when the user hits the condition — a page with code blocks, opening a chart tab, entering an admin area. Keep the static path free of that cost.
async function initSyntaxHighlighting() {
const { highlightAll } = await import('./prism.js');
highlightAll();
}
if (document.querySelector('pre code')) {
initSyntaxHighlighting();
} Initialisation without double-binding
Run setup when the DOM is ready, and make init idempotent — set a data attribute or flag so client navigations and hot reload do not attach duplicate listeners.
function initSiteHeader() {
const header = document.querySelector('.site-header');
if (!header || header.dataset.navBound === 'true') return;
header.dataset.navBound = 'true';
// attach listeners…
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initSiteHeader);
} else {
initSiteHeader();
}
SPAs and meta-frameworks have their own lifecycle hooks — React
effects, Vue onMounted, Svelte onMount, router
afterNavigate, and so on. The goal is the same: one module
owns the feature, init runs when the view is in the DOM, and guards
prevent duplicate work.
If you use Astro
Astro’s client router fires astro:page-load after each
navigation (including the first load). Listen there for enhancements
that must re-run when swapped HTML arrives — and still call init on
the first paint for static output:
document.addEventListener('astro:page-load', initSiteHeader);
initSiteHeader();
This site uses that pattern for shared chrome (for example header
navigation). You do not need Astro to follow the rest of this page —
DOMContentLoaded plus guards is enough on traditional
multi-page sites.
04 — Avoid
Complexity centralisation
Giant shared files and blocking scripts hide dependencies until production breaks.
-
monolithic
app.jsimported on every page -
render-blocking scripts without
deferor a documented reason -
static
importof large libraries on pages that never use them — prefer dynamicimport() - hidden dependencies via window globals or implicit load order
- duplicated utility logic copy-pasted across features
- importing framework runtime or hydration for content that needs no client behaviour
05 — Close
Reduce complexity
JavaScript structure should make the next change easier — not riskier.
Before adding a dependency or global script, ask what module owns
it, which pages load it, whether defer or dynamic import
is enough, and what happens on routes that do not need it. Default to
less JavaScript.
Related: hydration costs, lazy loading strategy, and JavaScript code review checklist.