Vanilla JavaScript can power a serious app. No framework badge required. The trick is the way you shape files, name things, and ship the bundle.
This is a field note from projects that crossed the thousand line mark and kept going. The goal is simple: stay readable, stay testable, ship fast.
Why pick vanilla for a large project?
APIs move, frameworks change, but the DOM stays. Vanilla gives you fewer moving parts and fewer upgrades that break your day.
With modern browsers loading ES modules and bundlers getting out of the way, plain JS is not a pain. It is a steady base.
How should the folders look?
Keep it boring. Boring scales. Group by feature, not by type. Keep shared stuff in a single home. Keep entry points clear.
src/
app/
index.js
router.js
events.js
store.js
features/
cart/
index.js
cartView.js
cartService.js
cart.test.js
profile/
index.js
profileView.js
profileService.js
shared/
dom.js
http.js
format.js
styles/
app.css
public/
index.html
Each feature owns its files. Shared helpers live in one place so they are easy to find and test.
Which module style works best right now?
Use ES modules in source. Browsers are catching up and bundlers love them for tree shaking. Node can run them through Babel or TypeScript when needed.
// shared/format.js
export function money(value) {
return "$" + Number(value).toFixed(2);
}
// features/cart/index.js
import { money } from "../../shared/format.js";
export function renderCartTotal(total) {
return money(total);
}
If you need to drop a simple script on a page without a build step, wrap it in an IIFE and expose a tiny API on a single global.
(function (root) {
const api = {
sum(a, b) { return a + b; }
};
root.MyApp = api;
})(window);
How do features talk to each other?
Keep cross feature calls rare. Use a small event hub. It keeps edges clean and makes tests easier.
// app/events.js
const topics = {};
export function on(topic, cb) {
(topics[topic] = topics[topic] || []).push(cb);
}
export function emit(topic, data) {
(topics[topic] || []).forEach(cb => cb(data));
}
export function off(topic, cb) {
topics[topic] = (topics[topic] || []).filter(fn => fn !== cb);
}
Now a feature can publish and another can react without tight wiring.
Where does state live?
Start simple. A tiny store with subscribe and set will carry you far. If it grows, split by domain.
// app/store.js
const state = { cart: [], user: null };
const subs = [];
export function getState() {
return Object.freeze({ ...state });
}
export function setState(patch) {
Object.assign(state, patch);
subs.forEach(fn => fn(getState()));
}
export function subscribe(fn) {
subs.push(fn);
return () => {
const i = subs.indexOf(fn);
if (i > -1) subs.splice(i, 1);
};
}
This is enough for most dashboards and sites without dragging extra libs.
How do we touch the DOM without pain?
Wrap tiny DOM helpers. Keep selectors and event binding in one place. Return functions that clean up.
// shared/dom.js
export const qs = (sel, el = document) => el.querySelector(sel);
export const on = (el, evt, fn) => {
el.addEventListener(evt, fn);
return () => el.removeEventListener(evt, fn);
};
export function html(el, tpl) { el.innerHTML = tpl; }
Views become tiny and focused. No mystery.
// features/cart/cartView.js
import { qs, on, html } from "../../shared/dom.js";
import { emit } from "../../app/events.js";
export function mount(root) {
const btn = qs("[data-add]", root);
const stop = on(btn, "click", () => emit("cart:add", { id: 1 }));
return () => stop();
}
export function template(items) {
return `
<ul>${items.map(i => `<li>${i.name}</li>`).join("")}</ul>
<button data-add>Add</button>
`;
}
What about tests?
Pick a runner that is fast. Jest works well for unit tests. For DOM bits, test pure functions and small interactions. Keep selectors close to the view.
// features/cart/cart.test.js
import { money } from "../../shared/format.js";
test("formats money", () => {
expect(money(3)).toBe("$3.00");
});
Wire tests with npm scripts so they run on every push and before release.
{
"scripts": {
"test": "jest --coverage",
"lint": "eslint src",
"format": "prettier --write \"src/**/*.js\"",
"build": "webpack --mode production",
"start": "webpack-dev-server --mode development"
}
}
Which tools help without taking over?
ESLint keeps style steady. Prettier kills bikeshedding. Webpack 4 sets mode and does tree shaking for free when you use ES modules. Parcel is nice for quick starts.
// webpack.config.js
module.exports = {
entry: "./src/app/index.js",
output: { filename: "app.js", path: __dirname + "/public" },
devtool: "source-map",
module: {
rules: [{ test: /\.js$/, exclude: /node_modules/, loader: "babel-loader" }]
}
};
Keep config small. Only add loaders when you actually need them.
How do we ship fast pages?
Split by route or feature. Let the entry point load the rest on demand with dynamic import. Cache with long names and serve from a CDN.
// app/router.js
export function goToProfile() {
import("../features/profile/index.js").then(m => m.mount());
}
Tree shaking trims dead code when you stick to ES import and export. Avoid giant utility bags that hide side effects.
How do we keep naming and docs sane?
Pick simple names that say what they do. One file does one job. A view renders. A service talks to the network. A store holds state.
Each folder gets a short readme with purpose, public API, and one example. That file saves hours of Slack time.
What about the network layer?
Wrap fetch once. Add base URL, headers, and error mapping. Keep it tiny and predictable. No surprises for callers.
// shared/http.js
const BASE = "/api";
export async function get(path) {
const res = await fetch(BASE + path, { credentials: "same-origin" });
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
}
How do we start the app?
Make one small boot file. It wires store, events, and the first view. Keep side effects out of modules so tests can import without running the world.
// app/index.js
import { mount as mountCart } from "../features/cart/cartView.js";
import { subscribe } from "./store.js";
document.addEventListener("DOMContentLoaded", () => {
const unmountCart = mountCart(document);
const unsub = subscribe(state => {
// update something
});
// expose a teardown for tests
window.__appTeardown = () => { unmountCart(); unsub(); };
});
What mistakes should we avoid?
Do not hide logic in build steps. Do not bury state in the DOM. Do not create a new pattern every month. The codebase is a team space, not a science fair.
What is the upgrade path?
Since the base is ES modules and small files, you can slide in TypeScript for a folder at a time, or swap Webpack for Rollup for a library build. No big bang rewrites.
Browsers keep adding features like native module scripts and dynamic import. Using these patterns today means less debt tomorrow.
Quick checklist
Feature folders with clear entry files. ES modules everywhere. Event hub for cross talk. Tiny store for state. DOM helpers to keep views clean. Tests and lint wired to npm scripts. Code splitting on routes or features.
If you are kicking off a new codebase this week, Webpack 4 and Babel 7 beta are already solid. Parcel works great for quick prototypes. Jest and ESLint are safe picks.
So where do we go from here?
Start with one feature in vanilla, shaped like this guide. Ship it. Watch bundle size and build time. If the team reads it easily after a week, you are on track.
Keep the patterns small and the contracts clear. Your future self will send coffee.