Patterns for large front ends without a framework – how teams keep vanilla (or lightly bundled) JavaScript maintainable when features, designers, and deadlines pile up.
Feature folders at scale
Each feature owns its UI, state, and adapters. Shared code earns its place in shared/ only when two features need it. Avoid a flat components/ dump where everything imports everything.
src/
app/
bootstrap.js // register features, read config
router.js // map paths to feature inits
features/
inbox/
index.js
inbox-store.js
inbox-view.js
settings/
index.js
shared/
ui/ // design-system wrappers
button.js
modal.js
lib/
http.js
format.js
Cross-feature imports go through public index.js files only. If inbox needs settings data, expose a narrow API – do not reach into settings internals.
Routing without a framework
const routes = new Map([
['/inbox', () => import('./features/inbox/index.js')],
['/settings', () => import('./features/settings/index.js')],
]);
let currentTeardown = null;
async function navigate(path) {
const loader = routes.get(path) ?? routes.get('/inbox');
const mod = await loader();
currentTeardown?.();
currentTeardown = mod.init(document.querySelector('#app'));
history.pushState({ path }, '', path);
}
window.addEventListener('popstate', () => {
navigate(location.pathname);
});
document.addEventListener('click', (event) => {
const link = event.target.closest('a[data-nav]');
if (!link) return;
event.preventDefault();
navigate(link.getAttribute('href'));
});
Dynamic import() loads features on demand. Each feature’s init returns cleanup – call it before swapping views to prevent listener leaks.
Design-system consumption
Design systems ship tokens (colours, spacing) and components (buttons, inputs). In plain JS, wrap markup and behaviour once, then reuse.
// shared/ui/button.js
export function createButton({ label, variant = 'primary', onClick }) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `btn btn--${variant}`;
btn.textContent = label;
btn.addEventListener('click', onClick);
return btn;
}
CSS comes from the design system stylesheet – your JS creates the right class names and ARIA. Do not fork button markup in twelve features.
Event bus vs explicit wiring
Global event buses hide dependencies. Prefer explicit imports and function calls for most cases. Custom events on a known DOM subtree are a middle ground – good for loosely coupled widgets in the same view.
// explicit - easy to trace
cartStore.subscribe((state) => badge.render(state.count));
// custom event - when features share one container
app.addEventListener('cart:updated', (event) => {
badge.render(event.detail.count);
});
Conventions that save arguments
- One naming scheme:
initFeature(root, config)everywhere. - Data attributes for DOM hooks:
data-inbox-list, not twenty unique IDs. - Errors bubble to a single
reportErrorhelper. - Document which layer may call
fetch– usually feature stores orshared/lib/http.js. - Lint and format in CI – eslint and prettier are not framework-specific.
Micro-frontends (when you really need them)
Splitting one product into independently deployed apps is a organisational tool, not a performance trick. If you need it, define clear custom-element or iframe boundaries and a shared shell for auth and navigation. Most teams should exhaust feature-folder discipline first.
Documentation and onboarding
A short ARCHITECTURE.md beats tribal knowledge: folder layout, how to add a feature, where state lives, how to run the dev server. Update it when the rules change or new hires will copy the wrong pattern from the oldest file in the repo.
The next tutorial is a mini project: an offline-capable notes or task app pulling together storage, service workers, and state.

