Clarks.lk is a shoe shop with no framework: two HTML documents, three hand-written scripts, and a JSON array standing in for a product database. Building a catalogue, a filter panel, a detail page and a favourites list without a component model teaches you exactly what a component model is for — not as an opinion you can be talked out of, but as a list of specific problems you have to solve yourself and mostly get wrong.
Update, September 2026: The site has since been changed on several of the points below: the favourites race is fixed, the code duplicated between the two pages is shared, the inline onclick handlers and duplicate DOM ids are gone, and the malformed product URL is corrected. What follows describes the code as it stood, which is what the argument is about.
It's the kind of project that's easy to be dismissive about in retrospect. I'm not going to be. But I am going to be accurate, which means correcting the version of this post I wrote in 2023.
How much of a no-framework site is actually your code?#
I opened the original with "twenty-five thousand lines of HTML, SCSS and jQuery". I went and counted. There are 24,451 lines of SCSS here across 218 files, and every one is Material Design for Bootstrap's own source, shipped inside the download. There is no package.json, no gulpfile, no build step — the pages load the pre-built css/mdb.min.css directly, so none of that SCSS is ever compiled by anything I wrote.
Here is what I actually wrote:
index.html 290
details.html 393
shoes.json 172
js/mainscript.js 303
js/detailsScript.js 165
js/uiscript.js 110
css/custom.styles.css 100
----
1533Fifteen hundred lines, not twenty-five thousand. I quoted a vendor's line count as my own for three years because nothing in the project let me tell the difference at a glance — which is the first honest lesson here, and it has nothing to do with rendering. Without a manifest there is no boundary between your code and someone else's. node_modules is a lot of things, but it is at least a line you can point at.
Is a JSON file good enough for a prototype database?#
Yes, and this is the part I'd do again. Thirteen records, ten distinct product names — colourways are separate entries sharing a name, which is why the catalogue deduplicates before rendering:
{
"id": "shoe1",
"name": "Pure Tone",
"gender": "Women",
"sizes": [4, 5, 6, 7, 9, 11],
"price": 75.0,
"colour": "Grey",
"colourCodes": ["Grey"],
"picture": "img/shoe1/1.jpg",
"url": "details.html?shoeId=shoe1"
}You get a real data shape, real async loading, and a seam where an API slots in later, without standing up a backend to prove a layout works — the same reason I start a Go service from its data shape. One array also forces you to name what "state" means: the full list, the current filter, the derived subset.
What it will not give you is a schema. One record reads "url": "details.html?shoe3-1.html" — the shoeId= is missing. The card renders, the link works, and the detail page throws, because the script parsing the query string finds no = to split on. A column constraint would have refused that row at write time, which is the whole argument in modelling traffic fines in Postgres.
Why is building HTML by string concatenation the wrong default?#
Without components, adding a product means building a string and injecting it. The interesting problem is not that interpolation is dangerous, but how many different escaping rules one template needs:
$(`.${genSection}-${element.id}`).append(`
<button class="colorBtn btn btn-sm btn-floating"
id="${element.id}" style="background-color:${color};"
onclick="changeColor('${element.id}')">
</button>`);Three interpolations, three different contexts: element.id into an HTML attribute value, color into a CSS declaration, and element.id again into a JavaScript string literal that is itself inside an HTML attribute. Each needs a different escape, and HTML-escaping would not save the onclick, because '); alert(1); (' is valid HTML text and still breaks out of the JS string. There is no single escape() correct for all three, which is why "escape your input" is bad advice — escaping is a property of the destination, not the value.
The fix isn't a better escaper; it is not producing a string. textContent has no parser behind it, so anything assigned to it is text, full stop. For structure, keep the markup in a <template> and clone it:
const node = document.getElementById('card-tpl').content.cloneNode(true);
node.querySelector('.card-title').textContent = element.name;
node.querySelector('.itemPrice').textContent = `Price : $${element.price}`;
node.querySelector('.card').dataset.shoeId = element.id; // not an onclick
grid.append(node);That last line matters as much as the escaping: a data- attribute read by one delegated listener removes the inline onclick, which is what lets you ship a strict Content Security Policy — a trade I worked through in writing a unified plugin for code blocks.
For markup you genuinely did not author — a review, a CMS field, a description with <em> in it — the platform now has Element.setHTML(), which parses and sanitises in one step, always dropping <script>, <iframe>, <object>, <embed> and every event-handler attribute. Check support first: it shipped in Firefox 148 and Chrome 146 this year but not Safari, so it is explicitly not Baseline and wants feature detection with a DOMPurify fallback. Its near-namesake setHTMLUnsafe() is Baseline and sanitises nothing.
Frameworks escape by default and you stop thinking about all of this, which is good right up until you meet dangerouslySetInnerHTML and don't know why it's named like that.
Should a filter rebuild the list or hide the nodes?#
Without a virtual DOM, filtering forces a choice: rebuild the grid from the data, or hide and show the nodes already there. Rebuilding is correct and throws away scroll position, focus and any in-flight state. Hiding is cheap and lets the DOM and the data disagree. Every virtual DOM implementation exists to avoid making that choice.
What I'd forgotten is that my own code makes it both ways, in one click handler:
$(".searchResults").html(""); // rebuild: destroy and regenerate
$(".loadingItems").hide("slow"); // hide: leave it in the DOM, toggle it
$(".searchItems").show();Search results are torn down and regenerated. The catalogue underneath is merely hidden — still in the document, still matched by every selector I write afterwards. That isn't a bug I noticed at the time; it's what happens when the decision is made at each call site by whoever is typing.
Both branches have better platform answers now. To keep a long list in the DOM, content-visibility: auto with contain-intrinsic-size lets the browser skip layout and paint for off-screen sections while they stay in the document — Baseline since September 2024, so Chrome 85, Firefox 125 and Safari 18.1 upwards, with the caveat that Safari's find-in-page has been inconsistent about skipped subtrees. To tear down and rebuild, document.startViewTransition() snapshots the old state, runs the mutation and cross-fades, so destroying forty cards animates instead of flickering.
I hit the underlying question — who owns the truth — from the other direction in audio state is not React state, where an element owned state React believed it owned. Here nothing owns it, and the DOM is the only record.
Are two separate HTML documents a mistake in 2026?#
index.html and details.html are genuinely separate documents. No router, no shared state, no hydration — a link is a full page load. (I called uiscript.js "shared interface behaviour" in 2023; it isn't shared — only index.html loads it, to wire up jQuery UI's widgets and fill the filter dropdowns.)
The costs are real and I paid all of them. Anything shared between pages lives in localStorage, which is why favList, addtoFavorites, refreshFavorites and generateFavoriteCard are copy-pasted verbatim from mainscript.js into detailsScript.js. My own README lists a bug I never fixed: favourites don't load properly on refresh. The root cause is in both files — fetch() fills the product array asynchronously, $(window).bind("load", ...) calls refreshFavorites(), and nothing sequences the two. It was never a page-load problem but an unordered pair of async operations, and a framework wouldn't have fixed it, only made it harder to see.
What I got wrong was the framing. I treated "a link is a full page load" as purely a cost, and that trade-off as the argument for SPAs. It reads as dated now. Multi-page architecture is something people choose deliberately: Astro and islands ship HTML by default and hydrate only the interactive parts, HTMX puts state back on the server on purpose, and this blog is a folder of prerendered HTML with no client runtime at all.
Two platform features have moved the honest cost since. Speculation rules prefetch or fully prerender the next document before the click, and browsers that don't understand the tag ignore it — Chrome and Edge 109 upwards, roughly four-fifths of traffic, Safari 26.2 behind a flag, Firefox not shipped:
<script type="speculationrules">
{ "prerender": [{ "where": { "href_matches": "/details.html?*" }, "eagerness": "moderate" }] }
</script>And cross-document view transitions animate between documents, the one thing an MPA could never fake. Chrome has had it since 126 and Safari since 18.2, but @view-transition is still limited availability rather than Baseline while Firefox is missing, so treat it as decoration:
@view-transition { navigation: auto; }Between them, my 2023 argument — SPA because navigation is expensive — is a much narrower claim than it was.
Where did the design system actually live?#
I claimed that with no components SCSS carried all the reuse, and that the stylesheet was the design system. It is the line I was most pleased with and it is false. The only two SCSS files here that are mine each contain a single line, // Your custom variables — MDB's placeholders, untouched. My real styling is css/custom.styles.css: 100 lines of plain CSS, no variables, no nesting, one media query at 768px. The reuse lived in MDB's utility classes sprayed across the markup. The design system was a vendor's, consumed as a minified artefact, and my contribution was overrides.
The observation underneath survives: with no component boundary, "this thing is like that thing" has nowhere to live but the stylesheet and the class attribute. What has changed is how much of that the platform handles unaided. Custom properties, @layer for cascade control (Chrome 99, Firefox 97, Safari 15.4, all in 2022), container queries, and native nesting — Baseline widely available since June 2026 — cover most of what Sass carried at this size. My worst CSS is .mobile { display: none } paired with .desktop { display: block }: two copies of the same content in the markup, toggled by a viewport query. A container query and one component makes it one copy, in the browser rather than a build step.
Is jQuery still worth knowing?#
It is, and it isn't dead — jQuery 4.0.0 shipped on 17 January 2026, the first major release in nearly a decade, with the source on ES modules, IE 10 and below dropped, and Trusted Types support so TrustedHTML can reach its manipulation methods under a CSP.
This project is on 3.6.0 and still calls $(window).bind(), deprecated since 3.0. But almost nothing here is really about jQuery. Swap $().append() for insertAdjacentHTML and every problem above is unchanged, because they all come from building HTML as text and letting the DOM be the only record of state. jQuery is the syntax, not the architecture.
What I'd keep#
The JSON-as-database approach, without hesitation. And the two-document structure, which I'd once have called a compromise and would now call a decision.
The rest I'd rebuild. Not because jQuery is bad — it does what it does perfectly well and is still carefully maintained. I'd rebuild it because of the two duplications I can measure. 150 of the 256 non-blank lines in index.html appear verbatim in details.html: head, navbar, favourites panel, footer, maintained by copy and paste. And four functions plus a module-level variable exist twice, once per page script, because there was no module system to share them. Change how a favourite is stored and you have to remember there are two of everything.
That isn't fashion. It's the same content expressed once instead of twice — and once you've felt that cost by hand, every component model you meet afterwards stops looking like ceremony.
Common questions#
Is it worth building a site without a framework to learn?#
Once, and for something with real behaviour in it — a list you filter, a detail view, state that outlives a navigation. A static page teaches you nothing new. The value is being made to answer questions a framework answers silently: where state lives, what happens to the DOM when the data changes, and which of your two copies of the truth is right. Expect to get several wrong; that is the lesson.
What is the safest way to insert dynamic content into the DOM without a framework?#
Use textContent for anything that is text, which is most things — it has no parser behind it and cannot become markup. For structure, put the markup in a <template>, clone it, and fill the holes with textContent and dataset rather than building a string. Only parse HTML when you genuinely have markup you did not author, and then use Element.setHTML(), which sanitises as it parses — shipped in Firefox 148 and Chrome 146 but not Safari, so feature-detect and fall back to DOMPurify.
When I filter a list by hand, should I rebuild the DOM or hide the nodes?#
Rebuild from the data and treat the DOM as output rather than storage. Hiding is cheaper for one release and then becomes the bug you cannot reproduce, because hidden nodes are still matched by every selector, still focusable unless you are careful, and still holding stale values. If rebuilding is genuinely too slow, reach for content-visibility: auto with contain-intrinsic-size — Baseline since September 2024 — rather than a visibility scheme you keep in sync by hand.
Are multi-page sites still a bad idea compared to single-page apps?#
No, and that framing has aged badly. Astro, islands and HTMX made the multi-page model a deliberate choice rather than a limitation, and the browser closed much of the gap: speculation rules prefetch or prerender the next document before the click (Chromium since 109, Safari behind a flag, Firefox not yet), and cross-document view transitions animate between documents (Chrome 126+, Safari 18.2+, not Baseline while Firefox is missing). What an MPA still costs is shared client state across navigations — small if your app is mostly reading and navigating, the whole product if it's an editor.
Is jQuery still maintained in 2026?#
Yes. jQuery 4.0.0 was released on 17 January 2026, the first major version since 2016 — ES modules packaged with Rollup, IE 10 and below dropped, deprecated helpers removed in favour of native equivalents, over 3 KB cut gzipped, and Trusted Types support so its manipulation methods work under a strict Content Security Policy. "Still maintained" is a fact, not a defence — the reason to leave jQuery behind on a new project is that the DOM APIs it wrapped are now good enough alone, not that the library rotted.